Service 单元测试可以证明名称规范化和业务异常,但它没有经过 Spring MVC,因此不能发现路径写错、JSON 字段不匹配、@Valid 没有生效或异常处理器没有注册等问题。
启动完整应用当然能够覆盖这些环节,但 Controller 测试并不需要数据库、连接池和全部业务 Bean。本章使用 @WebMvcTest 创建 MVC 测试切片,再通过 MockMvcTester 发起不占用真实端口的模拟请求。
1. 测试切片
测试切片只启用与目标技术有关的一部分自动配置和组件扫描。@WebMvcTest(HealthController.class) 主要包含:
HealthController与 MVC 基础设施;- JSON 消息转换、参数绑定和 Bean Validation;
@ControllerAdvice异常处理器;- Filter、Interceptor 和
WebMvcConfigurer等 Web 组件; MockMvc与基于 AssertJ 的MockMvcTester。
普通 @Service、@Repository 和 @ConfigurationProperties 不会因为存在于应用中就进入这个切片。Controller 所需的 HealthService 要由测试明确提供:
01@WebMvcTest02-> Spring MVC03-> HealthController04-> ApiExceptionHandler05-> MockMvcTester06-> @MockitoBean HealthService0708不加载09-> Repository10-> DataSource11-> Flyway12-> Web 服务器端口
切片的目的不是伪装成完整应用,而是让一层框架边界拥有更快、更准确的反馈。需要验证多层协作时,再扩大到 @SpringBootTest。
2. WebMvcTest
创建测试类并把 Service 替换为 Mockito 对象:
01package cn.usehook.spring.controller;0203import cn.usehook.spring.health.HealthService;04import cn.usehook.spring.health.HealthStatus;05import org.junit.jupiter.api.Test;06import org.springframework.beans.factory.annotation.Autowired;07import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;08import org.springframework.test.context.bean.override.mockito.MockitoBean;09import org.springframework.test.web.servlet.assertj.MockMvcTester;1011import static org.assertj.core.api.Assertions.assertThat;12import static org.mockito.BDDMockito.given;1314@WebMvcTest(HealthController.class)15class HealthControllerWebTests {1617private final MockMvcTester mvc;1819@MockitoBean20private HealthService healthService;2122@Autowired23HealthControllerWebTests(MockMvcTester mvc) {24this.mvc = mvc;25}2627@Test28void returnsDefaultHealthStatus() {29given(healthService.check())30.willReturn(new HealthStatus("UP", "spring"));3132assertThat(mvc.get().uri("/api/v1/health"))33.hasStatusOk()34.bodyJson()35.extractingPath("$.data.service")36.asString()37.isEqualTo("spring");38}39}
Spring Boot 4 中,@WebMvcTest 位于 org.springframework.boot.webmvc.test.autoconfigure,用于替换容器 Bean 的注解是 Spring Framework 提供的 @MockitoBean。这里没有使用旧包名或旧的 Bean Mock 注解。
MockMvcTester 的请求会经过 DispatcherServlet、HandlerMapping、参数解析和消息转换,但请求与响应由模拟 Servlet 对象承载,不会监听 localhost 端口。
@MockitoBean 把 Mock 注册成 ApplicationContext 中的 HealthService Bean,所以 Controller 的构造器依赖能够满足。这里测试的是 HTTP 边界,Service 业务规则已经由上一章的普通单元测试负责。
3. 请求与响应
POST 请求需要声明内容类型并提供 JSON 请求体:
01@Test02void acceptsHealthCheckRequest() {03given(healthService.check("PostgreSQL"))04.willReturn(new HealthStatus("UP", "postgresql"));0506assertThat(mvc.post().uri("/api/v1/health/check")07.contentType(MediaType.APPLICATION_JSON)08.content("""09{"service":"PostgreSQL"}10"""))11.hasStatusOk()12.hasContentTypeCompatibleWith(MediaType.APPLICATION_JSON)13.bodyJson()14.extractingPath("$.data.service")15.asString()16.isEqualTo("postgresql");17}
这段方法需要引入 org.springframework.http.MediaType。测试同时证明了路径与 HTTP 方法匹配、请求体能转换为 HealthCheckRequest、Controller 调用了预设 Service 行为,而且 HealthResponse 能转换成目标 JSON。
只断言整段 JSON 字符串通常会被字段顺序和无关字段绑住。针对接口契约的重要字段,可以使用 JSONPath;响应结构整体必须固定时,也可以把预期 JSON 放进测试资源并使用严格或宽松 JSON 比较。
请求头、缓存策略和内容类型属于 HTTP 契约,也应该在 Controller 测试中断言。Service 单元测试不应该知道 Cache-Control 或 application/json。
4. 校验与异常
空白服务名称应该在调用 Service 之前被 Bean Validation 拒绝:
01@Test02void rejectsBlankServiceName() {03assertThat(mvc.post().uri("/api/v1/health/check")04.contentType(MediaType.APPLICATION_JSON)05.content("""06{"service":" "}07"""))08.hasStatus(HttpStatus.BAD_REQUEST)09.bodyJson()10.extractingPath("$.errorCode")11.asString()12.isEqualTo("request_validation_failed");1314then(healthService).shouldHaveNoInteractions();15}
这段代码还要引入 org.springframework.http.HttpStatus,并静态引入 org.mockito.BDDMockito.then。最后一条断言证明失败发生在 MVC 参数校验阶段,而不是 Service 执行以后。
业务异常则由 Mock 主动抛出,用来验证 ApiExceptionHandler 的映射:
01@Test02void mapsMissingStatusToProblemDetail() {03given(healthService.getRequired("redis"))04.willThrow(new HealthStatusNotFoundException("redis"));0506assertThat(mvc.get().uri("/api/v1/health/status/redis"))07.hasStatus(HttpStatus.NOT_FOUND)08.bodyJson()09.extractingPath("$.errorCode")10.asString()11.isEqualTo("health_status_not_found");12}
还需要引入 HealthStatusNotFoundException。这项测试不再验证异常类怎样产生;它只验证异常离开 Controller 调用链后,能够变成正确的 HTTP 状态和问题详情字段。
5. 测试边界
常见 Spring 测试方式的范围如下:
| 方式 | 加载范围 | 当前用途 |
|---|---|---|
直接 new | 普通 Java 对象 | Service 与转换规则 |
@WebMvcTest | MVC 切片 | 路径、JSON、校验与异常响应 |
@DataJpaTest | JPA 切片 | 实体映射与 Repository 查询 |
@SpringBootTest | 完整应用上下文 | 多层 Bean、配置和事务协作 |
@SpringBootTest(RANDOM_PORT) | 完整上下文与真实端口 | HTTP 到基础设施的端到端链路 |
不要把 @WebMvcTest 中缺失的 Service、Repository 逐个 @Import 进来。这样最终会手工拼成不完整的全上下文,既失去切片速度,也容易遗漏真实自动配置。需要多层协作时,直接选择语义明确的 @SpringBootTest。
后面的 JPA 章节会在实体与 Repository 已经出现后使用 @DataJpaTest,Testcontainers 章节则验证真实 PostgreSQL、Flyway、事务和 Service 的整体协作。测试工具的选择由要证明的边界决定,而不是由注解功能多少决定。
6. 执行测试
运行 Controller 测试:
1./mvnw -Dtest=HealthControllerWebTests test
如果测试上下文启动失败,应先区分原因:Controller 构造器依赖是否都由 @MockitoBean 提供,异常处理器是否位于应用扫描范围,测试 Starter 是否存在,以及导入的 @WebMvcTest 是否来自 Spring Boot 4 的包。
测试通过后,HTTP 成功、校验失败和业务异常三条主要路径都拥有自动化保护。下一篇为同一组接口加入 Spring Security,解释安全 Filter 链为什么位于 MVC 之前,并补充认证、授权、CORS 与 CSRF 的边界;加入安全后,这些 MVC 测试也会显式携带身份,而不是绕过安全规则。