跳到主内容
创建时间: 2026-09-16最后更新: 2026-09-16

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 要由测试明确提供:

web-slice.txt
01
@WebMvcTest
02
-> Spring MVC
03
-> HealthController
04
-> ApiExceptionHandler
05
-> MockMvcTester
06
-> @MockitoBean HealthService
07
08
不加载
09
-> Repository
10
-> DataSource
11
-> Flyway
12
-> Web 服务器端口

切片的目的不是伪装成完整应用,而是让一层框架边界拥有更快、更准确的反馈。需要验证多层协作时,再扩大到 @SpringBootTest。

2. WebMvcTest

创建测试类并把 Service 替换为 Mockito 对象:

src/test/java/cn/usehook/spring/controller/HealthControllerWebTests.java
01
package cn.usehook.spring.controller;
02
03
import cn.usehook.spring.health.HealthService;
04
import cn.usehook.spring.health.HealthStatus;
05
import org.junit.jupiter.api.Test;
06
import org.springframework.beans.factory.annotation.Autowired;
07
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
08
import org.springframework.test.context.bean.override.mockito.MockitoBean;
09
import org.springframework.test.web.servlet.assertj.MockMvcTester;
10
11
import static org.assertj.core.api.Assertions.assertThat;
12
import static org.mockito.BDDMockito.given;
13
14
@WebMvcTest(HealthController.class)
15
class HealthControllerWebTests {
16
17
private final MockMvcTester mvc;
18
19
@MockitoBean
20
private HealthService healthService;
21
22
@Autowired
23
HealthControllerWebTests(MockMvcTester mvc) {
24
this.mvc = mvc;
25
}
26
27
@Test
28
void returnsDefaultHealthStatus() {
29
given(healthService.check())
30
.willReturn(new HealthStatus("UP", "spring"));
31
32
assertThat(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 请求体:

HealthControllerWebTests.java
01
@Test
02
void acceptsHealthCheckRequest() {
03
given(healthService.check("PostgreSQL"))
04
.willReturn(new HealthStatus("UP", "postgresql"));
05
06
assertThat(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 拒绝:

HealthControllerWebTests.java
01
@Test
02
void rejectsBlankServiceName() {
03
assertThat(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");
13
14
then(healthService).shouldHaveNoInteractions();
15
}

这段代码还要引入 org.springframework.http.HttpStatus,并静态引入 org.mockito.BDDMockito.then。最后一条断言证明失败发生在 MVC 参数校验阶段,而不是 Service 执行以后。

业务异常则由 Mock 主动抛出,用来验证 ApiExceptionHandler 的映射:

HealthControllerWebTests.java
01
@Test
02
void mapsMissingStatusToProblemDetail() {
03
given(healthService.getRequired("redis"))
04
.willThrow(new HealthStatusNotFoundException("redis"));
05
06
assertThat(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 与转换规则
@WebMvcTestMVC 切片路径、JSON、校验与异常响应
@DataJpaTestJPA 切片实体映射与 Repository 查询
@SpringBootTest完整应用上下文多层 Bean、配置和事务协作
@SpringBootTest(RANDOM_PORT)完整上下文与真实端口HTTP 到基础设施的端到端链路

不要把 @WebMvcTest 中缺失的 Service、Repository 逐个 @Import 进来。这样最终会手工拼成不完整的全上下文,既失去切片速度,也容易遗漏真实自动配置。需要多层协作时,直接选择语义明确的 @SpringBootTest。

后面的 JPA 章节会在实体与 Repository 已经出现后使用 @DataJpaTest,Testcontainers 章节则验证真实 PostgreSQL、Flyway、事务和 Service 的整体协作。测试工具的选择由要证明的边界决定,而不是由注解功能多少决定。

6. 执行测试

运行 Controller 测试:

terminal.bash
1
./mvnw -Dtest=HealthControllerWebTests test

如果测试上下文启动失败,应先区分原因:Controller 构造器依赖是否都由 @MockitoBean 提供,异常处理器是否位于应用扫描范围,测试 Starter 是否存在,以及导入的 @WebMvcTest 是否来自 Spring Boot 4 的包。

测试通过后,HTTP 成功、校验失败和业务异常三条主要路径都拥有自动化保护。下一篇为同一组接口加入 Spring Security,解释安全 Filter 链为什么位于 MVC 之前,并补充认证、授权、CORS 与 CSRF 的边界;加入安全后,这些 MVC 测试也会显式携带身份,而不是绕过安全规则。