主题
界面
主题
《Spring Boot 自动配置与项目结构》、《Spring MVC 请求映射与参数绑定》、《参数校验与全局异常处理》 三节下来,你已经有了一个能跑的 Web 应用。本节回答一个必然的问题:
这些接口该怎么测?
端到端测试(真实浏览器 / 真实环境)
┌──────────────────────────────┐
│ 集成测试(@SpringBootTest) ← 下一节
┌──────────────────────────────┐
│ 切片测试(@WebMvcTest) ← 本节
┌──────────────────────────────┐
│ 单元测试(纯 JUnit + Mockito) ← T1-001 ~ T1-012
└──────────────────────────────┘切片测试站在中间:它启动 Spring 容器,但只启动一小片——够测 HTTP 那一层,又不至于慢到没人愿意跑。
完成本节后,你应该能够:
@WebMvcTest 加载什么、不加载什么;MockMvc 与"真实 HTTP 请求"的区别;@MockitoBean 替换被切片排除的依赖(并知道它替代了 @MockBean);status() / header() / jsonPath() 三类断言写测试;andDo(print()) 观察 MockMvc 处理的请求与响应详情;先看"纯单元测试"测不到什么。
在 《Spring MVC 请求映射与参数绑定》 里,OrderController 的职责包括:
| 职责 | 纯单元测试能测吗 |
|---|---|
调 Service、组装 ResponseEntity | ✅ 能 |
@GetMapping("/{orderNo}") 的路由对不对 | ❌ 不能——那要 DispatcherServlet |
@RequestBody 能不能正确反序列化 | ❌ 不能——那要 HttpMessageConverter |
@Valid 校验失败会不会返回 422 | ❌ 不能——那要完整的参数解析器链 |
| 异常处理器会不会翻译成 404 | ❌ 不能——那要 HandlerExceptionResolver |
这些"框架帮我做的事",只有让框架真跑起来才测得到。
那为什么不用 @SpringBootTest 全都测(《Spring 集成测试与 Testcontainers 概念》)?因为慢——启动完整容器、连数据库、起网络端口,一套下来几十秒。
切片测试取中间:只加载 Web 那一层,于是:
路由、参数绑定、校验、异常处理 → 都测得到 ✓
数据库、Service、外部服务 → 用 mock 顶替(因为本来也不是这一层的事)| 层次 | 技术 | 加载范围 | 能测到什么 |
|---|---|---|---|
| 单元 | JUnit + Mockito | 无容器 | 一个类的逻辑 |
| 切片 | @WebMvcTest / @DataJpaTest | 只加载一层 | 该层的框架行为 |
| 集成 | @SpringBootTest | 完整容器 | 跨层协作、真实依赖 |
| 端到端 | Selenium / Playwright | 真实部署的环境 | 用户能感知的一切 |
切片(slice)这个词的含义:把应用"切"一片下来测——@WebMvcTest 切的是"Web 层"。
其他常见的切片注解:
| 注解 | 切哪一片 |
|---|---|
@WebMvcTest | Web 层(Controller + MVC 基础设施) |
@DataJpaTest | 数据层(Repository + 内嵌数据库) |
@JsonTest | JSON 序列化(只测 Jackson) |
@RestClientTest | REST 客户端 |
共同点:都只加载自己那一片的 Bean,其余用 mock 或排除 ✓
在测试金字塔里的位置(T1-005):
/\ E2E 少 慢 贵
/ \
/────\ 集成
/ \
/────────\ 切片 ← 本节
/ \
/────────────\ 单元 多 快 便宜@WebMvcTest 加载什么 @WebMvcTest(OrderController.class) // ← 指定要测的 Controller
class OrderControllerSliceTest { }它会加载:
| 类别 | 具体 |
|---|---|
| 指定的 Controller | OrderController(写了参数时只加载它) |
| Spring MVC 基础设施 | DispatcherServlet、HandlerMapping、参数解析器链、消息转换器 |
@ControllerAdvice | GlobalExceptionHandler ✓ |
@JsonComponent、Filter、WebMvcConfigurer | 与 Web 相关的组件 |
Converter / GenericConverter | 类型转换器 |
它不会加载:
| 类别 | 具体 | 后果 |
|---|---|---|
@Service | OrderService | 必须用 @MockitoBean 提供 |
@Component / @Repository | 其他业务组件 | 需要时手动 mock |
普通 @Configuration 的 @Bean | 除了 Web 相关 | 需要时 @Import |
| 数据源、事务管理器 | 自动配置被排除 | 想测数据库要用 @DataJpaTest |
这张表解释了切片测试最典型的报错:
No qualifying bean of type 'cn.kb.learn.web.service.OrderService' available——Service 没被加载,你得自己给一个 ✓
一个值得注意的点:@ControllerAdvice 会被加载 ✓ 所以 《参数校验与全局异常处理》 写的 GlobalExceptionHandler 在切片测试里是生效的——这也是为什么"404 / 422 的响应体"能被测到。
如果 @RestControllerAdvice 不被加载,"异常 → 状态码"这条链路就成了切片测试的盲区——Spring 把它包含进来,正是为了让切片测试能覆盖"错误响应"这一层 ✓
@Autowired
private MockMvc mockMvc;
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"productId":"P-1","quantity":2,"address":"上海市浦东新区"}
"""))
.andExpect(status().isCreated())
.andExpect(header().string("Location", "/api/orders/ORD-1"))
.andExpect(jsonPath("$.orderNo").value("ORD-1"));MockMvc 做了什么:
真实环境: HTTP 请求 → Tomcat(网络)→ DispatcherServlet → Controller
MockMvc: Mock 请求 ─────────────→ DispatcherServlet → Controller
↑ 不经过网络、不起端口| 对比 | MockMvc | 真实请求 |
|---|---|---|
| 启动服务器 | ❌ 不需要 | ✅ 需要 |
| 经过网络栈 | ❌ 不经过 | ✅ 经过 |
| 速度 | 快(毫秒级) | 慢(毫秒到秒级,取决于服务) |
| 能测到 Servlet 容器行为 | ❌ 不能 | ✅ 能 |
| 能测到过滤器(Filter) | ✅ 能(配了就会跑) | ✅ 能 |
| 能测到拦截器(Interceptor) | ✅ 能 | ✅ 能 |
注意第 4 行:MockMvc 绕过了 Tomcat——所以"连接超时、请求体大小限制、HTTP/2、Chunked 编码"这类容器层面的行为测不到 ✗ 那些得靠集成测试或专门的容器测试。
MockMvc 的三类断言:
| 断言 | 用途 | 例子 |
|---|---|---|
status() | 状态码 | status().isCreated() / isUnprocessableEntity() / isNotFound() |
header() | 响应头 | header().string("Location", "/api/orders/ORD-1") |
jsonPath() | JSON 结构 | jsonPath("$.orderNo").value("ORD-1") |
content() | 原始响应体 | content().string(containsString("...")) |
redirectedUrl() | 重定向地址 | redirectedUrl("/login") |
jsonPath 的常用写法:
| 表达式 | 含义 | 示例 |
|---|---|---|
$.field | 根对象字段 | jsonPath("$.code").value("VALIDATION_FAILED") |
$.a.b | 嵌套字段 | jsonPath("$.data.orderNo") |
$.array.length() | 数组长度 | jsonPath("$.details.length()").value(2) |
$.array[0].field | 数组元素 | jsonPath("$.data[0].productId") |
$.field + exists() | 字段存在 | jsonPath("$.traceId").exists() |
$ + doesNotExist() | 整个响应体为空 | jsonPath("$").doesNotExist() |
@MockitoBean:切片测试的"补位" @WebMvcTest(OrderController.class)
class OrderControllerSliceTest {
@Autowired
private MockMvc mockMvc;
/** Spring Boot 3.4 的新写法:替代已废弃的 @MockBean */
@MockitoBean
private OrderService orderService;
}它做两件事:
⚠️ 版本迁移提示:
| Spring Boot 版本 | 注解 | 状态 |
|---|---|---|
| ≤ 3.3 | @MockBean / @SpyBean | 可用 |
| 3.4+ | @MockitoBean / @MockitoSpyBean | 推荐 |
| 未来 | @MockBean | 会被移除 |
包路径的变化(这是从旧代码迁移时最容易卡住的地方):
// 旧(已废弃)
import org.springframework.boot.test.mock.mockito.MockBean;
// 新
import org.springframework.test.context.bean.override.mockito.MockitoBean;注意:新版注解在 org.springframework.test.context.bean.override.mockito 包下——不再是 spring-boot 的包 ✓ 因为它被提升到了 Spring Framework 层面(@MockitoBean 也能用在非 Boot 的 Spring 测试里了)。
@MockitoBean 与 @Mock 的区别(容易混):
@Mock(Mockito) | @MockitoBean(Spring) | |
|---|---|---|
| 谁创建 | Mockito 扩展(@ExtendWith(MockitoExtension.class)) | Spring 测试上下文 |
| 放进容器 | ❌ 只放进测试类字段 | ✅ 替换容器里的 Bean |
| 用在 | 纯单元测试 | 需要 Spring 容器的测试 |
能否用 @Autowired 注入 | ❌ | ✅ |
一句话:"容器里有没有这个 Bean"是区分两者的关键 ✓
andDo(print()) 看清中间发生了什么 这是写切片测试时最有用的工具:
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"productId":"P-1","quantity":2,"address":"上海市浦东新区"}"""))
.andDo(print()) // ← 打印请求、处理器、响应
.andExpect(status().isCreated());输出(真实运行结果):
MockHttpServletRequest:
HTTP Method = POST
Request URI = /api/orders
Headers = [Content-Type:"application/json;charset=UTF-8", Content-Length:"67"]
Body = {"productId":"P-1","quantity":2,"address":"上海市浦东新区"}
Handler:
Type = cn.kb.learn.web.controller.OrderController
Method = cn.kb.learn.web.controller.OrderController#create(CreateOrderRequest)
MockHttpServletResponse:
Status = 201
Headers = [Location:"/api/orders/ORD-1", Content-Type:"application/json"]
Content type = application/json
Body = {"orderNo":"ORD-1","productId":"P-1","quantity":2,"amountInCents":39800,"status":"CREATED","createdAt":"2026-09-21T06:21:40Z"}三部分信息,分别回答三个问题:
| 部分 | 回答的问题 | 排查什么 |
|---|---|---|
| MockHttpServletRequest | 我发出去的请求到底是什么? | 编码对不对、Content-Type 对不对、body 有没有被截断 |
| Handler | 这个请求被哪个方法接住了? | 路由 404 / 匹配错方法 |
| MockHttpServletResponse | 响应到底是什么? | 状态码、响应头、body 结构 |
Handler 那一节尤其有用:"为什么 404"的答案常常就在这里——如果 Type 显示 ResourceHttpRequestHandler,说明请求落到静态资源处理器了(即没有 Controller 匹配)✓
⚠️ 关于中文的坑:上面输出里的 Body 在控制台可能显示成乱码(如 "address":"�Ϻ����ֶ�����")——
先分清是"显示问题"还是"数据问题":
| 现象 | 原因 | 怎么判断 |
|---|---|---|
| 控制台乱码,响应正确 | 控制台编码(Windows 默认 GBK) | 看响应 body 里的中文正不正常 |
| 响应里也是乱码 | 编码配置问题(如 produces 没带 charset) | 看 Content type 值 |
Windows 下让控制台正确显示:
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
chcp 65001本节的 7 个测试覆盖的东西:
| 测试 | 验证的框架行为 |
|---|---|
| 创建成功 | @RequestBody 反序列化 + ResponseEntity.created 的 201 与 Location |
| 校验失败 | @Valid 触发 + GlobalExceptionHandler 翻译成 422 + details 结构 |
| 资源不存在 | Service 抛异常 → 全局处理器翻译成 404 |
| 路径变量绑定 | @PathVariable 绑定(这依赖 《Spring MVC 请求映射与参数绑定》 5.4 节的 -parameters!) |
| 查询参数绑定 | @RequestParam + defaultValue |
| 类型不匹配 | 参数解析失败 → 400 |
| 删除成功 | 204 无响应体 |
注意第 4 条:切片测试会依赖 -parameters 编译标志——如果漏了它,这些测试会报错(Name for argument not specified)✓
这也是切片测试的一个额外价值:它能在 CI 上"替你把
@PathVariable全跑一遍"——编译配置错了,测试立刻会红 ✓
切片测试覆盖不到的:
| 覆盖不到 | 为什么 | 该用什么测 |
|---|---|---|
| Service 的真实逻辑 | 被 mock 了 | 单元测试(T1-003) |
| 数据库读写 | 没有数据源 | @DataJpaTest / 集成测试 |
| 跨层协作(Controller → Service → 存储) | Service 是假的 | 集成测试(T1-017) |
| Tomcat 的行为(超时、请求体大小) | 没有真实容器 | 集成测试 / 容器测试 |
序列化细节(如 non_null 配置) | 配置可能被切片简化 | @JsonTest / 集成测试 |
最后一条尤其容易被忽略:@WebMvcTest 会加载 Jackson 的自动配置,所以大部分序列化行为是对的——但如果你在 application.yml 里配了自定义的序列化选项(如本项目配的 default-property-inclusion: non_null),切片测试也会读到这份配置 ✓ 所以这一块是覆盖到的。
真正读不到的是:只在完整启动流程里才会生效的东西(如 ApplicationRunner、@PostConstruct 里的初始化逻辑)✓
@WebMvcTest(OrderController.class)
class MinimalTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private OrderService orderService;
@Test
void should_return_200() throws Exception {
given(orderService.find("ORD-1")).willReturn(sampleOrder());
mockMvc.perform(get("/api/orders/ORD-1")).andExpect(status().isOk());
}
}三个必要元素:@WebMvcTest(切片范围)、MockMvc(发请求)、@MockitoBean(补上被排除的 Service)。
见第 7 节。
@Test
void should_calculate_correct_amount() throws Exception {
// ✗ 这是 Service 的职责,被 mock 掉了根本测不到
given(orderService.create(any())).willReturn(sampleOrder());
mockMvc.perform(post("/api/orders").content(...))
.andExpect(jsonPath("$.amountInCents").value(39800));
// ↑ 这个值来自打桩,不是来自真实计算
}问题:这个断言只是在复述打桩时写下的值——T1-012 讲的"自证预言"在切片测试里的翻版 ✗
正确的分工:
| 想验证什么 | 该写在哪 |
|---|---|
| 金额算得对不对 | OrderService 的单元测试 |
| 金额字段有没有正确序列化成 JSON | 切片测试(值来自打桩,只看结构) |
| 从请求到落库的完整链条 | 集成测试(T1-017) |
判断标准:这个断言失败时,我应该去改哪一层的代码?
文件:labs/spring-boot-web/src/test/java/cn/kb/learn/web/controller/OrderControllerSliceTest.java
@WebMvcTest(OrderController.class)
@DisplayName("订单接口切片测试(@WebMvcTest + MockMvc)")
class OrderControllerSliceTest {
@Autowired
private MockMvc mockMvc;
/** Spring Boot 3.4 的新写法:替代已废弃的 @MockBean */
@MockitoBean
private OrderService orderService;
private static final OrderResponse SAMPLE = new OrderResponse(
"ORD-1", "P-1", 2, 39800L, "CREATED", Instant.parse("2026-09-21T06:21:40Z"));
@Test
@DisplayName("创建成功:201 + Location 头 + JSON 字段")
void should_return_201_with_location_when_create_succeeds() throws Exception {
given(orderService.create(any())).willReturn(SAMPLE);
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"productId":"P-1","quantity":2,"address":"上海市浦东新区"}
"""))
.andDo(print())
.andExpect(status().isCreated())
.andExpect(header().string("Location", "/api/orders/ORD-1"))
.andExpect(jsonPath("$.orderNo").value("ORD-1"))
.andExpect(jsonPath("$.amountInCents").value(39800));
}
@Test
@DisplayName("校验失败:422 + 逐字段 details(根本不会调到 Service)")
void should_return_422_with_field_details_when_validation_fails() throws Exception {
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"productId":"P-2","quantity":0,"address":""}
"""))
.andDo(print())
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"))
.andExpect(jsonPath("$.details.length()").value(2))
.andExpect(jsonPath("$.traceId").exists());
// 关键断言:参数校验在进入 Controller 之前就被拦住了
verifyNoInteractions(orderService);
}
@Test
@DisplayName("资源不存在:Service 抛异常,由全局处理器翻译成 404")
void should_return_404_when_service_throws_not_found() throws Exception {
given(orderService.find("ORD-999"))
.willThrow(new ResourceNotFoundException("订单", "ORD-999"));
mockMvc.perform(get("/api/orders/ORD-999"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND"))
.andExpect(jsonPath("$.message").value("订单不存在:ORD-999"));
}
@Test
@DisplayName("路径变量绑定 + 查询参数绑定")
void should_bind_path_variable_and_query_params() throws Exception {
given(orderService.find("ORD-7")).willReturn(SAMPLE);
given(orderService.list(eq(2), eq(5), eq("CREATED")))
.willReturn(new PageResponse<>(List.of(SAMPLE), 2, 5, 1, 1));
mockMvc.perform(get("/api/orders/ORD-7"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.orderNo").value("ORD-1"));
mockMvc.perform(get("/api/orders").param("page", "2").param("size", "5").param("status", "CREATED"))
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.page").value(2))
.andExpect(jsonPath("$.size").value(5));
}
@Test
@DisplayName("查询参数用默认值:不传 page/size 时是 1 和 20")
void should_use_default_query_params() throws Exception {
given(orderService.list(anyInt(), anyInt(), any()))
.willReturn(new PageResponse<>(List.of(), 1, 20, 0, 0));
mockMvc.perform(get("/api/orders"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.page").value(1))
.andExpect(jsonPath("$.size").value(20));
}
@Test
@DisplayName("类型不匹配:page=abc 返回 400 而不是 500")
void should_return_400_when_query_param_type_mismatch() throws Exception {
mockMvc.perform(get("/api/orders").param("page", "abc"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("TYPE_MISMATCH"))
.andExpect(jsonPath("$.message").value("参数类型不正确:page"));
}
@Test
@DisplayName("删除成功返回 204(无响应体)")
void should_return_204_when_delete_succeeds() throws Exception {
mockMvc.perform(delete("/api/orders/ORD-1"))
.andExpect(status().isNoContent())
.andExpect(jsonPath("$").doesNotExist());
}
}运行命令:
cd labs
./mvnw -pl spring-boot-web test预期输出:
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 19.69 s -- in cn.kb.learn.web.controller.OrderControllerSliceTest七个测试各自的耗时(从 surefire XML 报告读出):
should_return_422_with_field_details_when_validation_fails → 2.869 s ← 第一个跑的,含上下文启动
should_use_default_query_params → 0.327 s
should_return_204_when_delete_succeeds → 0.033 s
should_return_400_when_query_param_type_mismatch → 0.084 s
should_bind_path_variable_and_query_params → 0.107 s
should_return_404_when_service_throws_not_found → 0.036 s
should_return_201_with_location_when_create_succeeds → 0.050 s这张表说明了测试成本的结构:
| 观察 | 结论 |
|---|---|
| 第一个测试 2.869 s | 绝大部分成本是"启动测试上下文"(加载 MVC 基础设施、扫描、创建 Bean) |
| 其余六个合计 0.637 s | 测试方法本身极快(毫秒级)——它们只是"发个假请求、看看响应" |
Time elapsed: 19.69 s 远大于各方法之和 | 这个数字是整类的墙钟时间,包含类加载、JVM 预热、报告生成等 |
关键认知:Spring 测试的成本在"启动",不在"执行"。
这解释了两件事:① 为什么"一个类里多写几个测试"几乎不增加成本;② 为什么复用同一个测试上下文(《Spring 集成测试与 Testcontainers 概念》)能省下大量时间。
⚠️ 一个诚实的提醒:上面 19.69 s 这个数字本机波动很大(同一份代码另一次跑是 13.62 s)。别拿单次测量当基准——要看的是"成本花在哪里",而不是"到底几秒"。
OrderController.create 的第一行打断点:观察 request 已经被反序列化好了——反序列化发生在方法调用之前(《Spring MVC 请求映射与参数绑定》第 5.1 节);GlobalExceptionHandler.handleValidation 处打断点:观察它确实被切片测试加载了(虽然它不在 @WebMvcTest 的参数里);@MockitoBean 的效果:在 OrderService 的构造器上打断点——它不会被调用(因为那个 Bean 是 mock 出来的);@MockitoBean:观察报错 No qualifying bean of type 'OrderService'——这就是"切片不加载 @Service"的现场。@MockBean import org.springframework.boot.test.mock.mockito.MockBean; // ✗ 3.4 起已废弃
@MockBean
private OrderService orderService;症状:编译警告 @MockBean has been deprecated;未来版本会直接编译失败。
修复:换成 @MockitoBean,注意包路径变了:
import org.springframework.test.context.bean.override.mockito.MockitoBean; // ✓
@MockitoBean
private OrderService orderService;顺带:@SpyBean → @MockitoSpyBean(同样的迁移)✓
@WebMvcTest 里注入 @Service 失败 @WebMvcTest(OrderController.class)
class MyTest {
@Autowired
private OrderService orderService; // ✗ 切片不加载 @Service
}症状:
No qualifying bean of type 'cn.kb.learn.web.service.OrderService' available两种修复:
| 想干什么 | 怎么做 |
|---|---|
| 只想测 Web 层 | 用 @MockitoBean 提供一个假的 |
| 真的需要真实 Service | 换 @SpringBootTest(那就不是切片测试了) |
@WebMvcTest 忘了指定 Controller @WebMvcTest // 不写参数
class MyTest { }后果:加载所有 Controller——项目里 Controller 一多,上下文就变大、变慢,且可能因为别的 Controller 依赖缺失而启动失败 ✗
修复:始终写清要测哪个:
@WebMvcTest(OrderController.class) // ✓这既是性能优化,也是"测试意图"的声明 ✓
jsonPath 写错路径 .andExpect(jsonPath("$.details").value(2)); // ✗ details 是数组,不是 2
.andExpect(jsonPath("$.details.length()").value(2)); // ✓ 要用 length()
.andExpect(jsonPath("$.data[0]").value(...)); // ✓ 数组元素用下标症状:
No value at JSON path "$.details"排查习惯:先用 andDo(print()) 把真实响应的 JSON 打出来,再对着它写 jsonPath ✓
MockMvc 当成"端到端测试" mockMvc.perform(get("/api/orders/ORD-1"))
.andExpect(status().isOk());
// 就断言一个状态码,就以为"接口没问题"问题:MockMvc 不经过网络,也不经过真实的 Service 与存储 ✗
| 这个测试证明的 | 这个测试没有证明的 |
|---|---|
| 路由配对了 | 真实数据库里能不能查到 |
| Service 被调用了 | Service 返回的数据对不对 |
| 序列化没抛异常 | Tomcat 会不会因为请求体过大拒绝请求 |
修复:MockMvc 的断言要"重结构、轻数值"(结构来自框架,数值来自打桩);把"数值正确性"留给单元测试与集成测试 ✓
本轮的实测结果值得记下来:
切片测试(7 个): 13.62 s / 19.69 s(两次运行)
集成测试(4 个): 3.236 s / 7.438 s切片测试反而更慢 ✗ 为什么?
| 原因 | 说明 |
|---|---|
| 成本在"启动上下文",不在"加载多少 Bean" | MVC 切片的自动配置筛选并不便宜 |
| 集成测试没有外部依赖 | 本项目的存储是内存 Map——如果换成真实数据库,集成测试会慢得多 |
| 本机波动大 | 同一份代码两次运行可以差 2 倍 |
正确的理解:
切片测试的价值不在"更快",而在"更聚焦": 加载的 Bean 少 → 测试间的相互影响小 → 失败时更好定位。
真正让集成测试变慢的是外部依赖(数据库、网络),不是"启动了完整的 Spring 容器"本身。
什么时候切片测试会明显更快:项目大、Bean 多、集成测试要连数据库的时候 ✓
目标:亲手验证"切片测试覆盖了哪些框架行为"。
步骤:
spring-boot-web/pom.xml 里的 <parameters>true</parameters> 注释掉,重新运行——观察哪些测试失败、报什么错(提示:Name for argument not specified);OrderController.create 第一行打断点,单跑校验失败的那个测试——断点不会被命中;@MockitoBean 注释掉,观察报错信息;jsonPath("$.status").value("CREATED")(注意这个值来自打桩的 SAMPLE);target/surefire-reports/TEST-*.xml 里读出每个测试方法的 time,核对"第一个测试远慢于其他";Time elapsed 的波动——理解"单次测量不可靠"。验收方式:能说出第 2 步的报错、第 3 步"断点不命中"的原因。
扩展任务:给 OrderController 加一个新接口(如 GET /api/orders/count?status=CREATED),先写切片测试再实现(T1-006 的测试先行)——体验"测试驱动地把路由、绑定、响应结构都定下来"。
基础题
@WebMvcTest 会加载项目里所有的 @Service。(对 / 错)MockMvc 与"用 TestRestTemplate 发真实请求"的核心区别是什么?代码阅读题
@WebMvcTest(ProductController.class)
class ProductControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private ProductService productService;
@Test
void test1() throws Exception {
given(productService.find(1L)).willReturn(new Product(1L, "键盘"));
mockMvc.perform(get("/api/products/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("键盘"));
}
@Test
void test2() throws Exception {
mockMvc.perform(get("/api/products/999"))
.andExpect(status().isNotFound());
}
}test2 会通过吗?为什么?test2 通过,需要补什么?(给出代码)编程题
CreateOrderRequest 写完整的校验切片测试: productId 为空、productId 为纯空白、quantity = 0、quantity = 101、address 超过 200 字符;@ParameterizedTest + @MethodSource 参数化(T1-002);code 是 VALIDATION_FAILED、details 里包含对应的字段名;verifyNoInteractions);综合任务
POST /api/users/register(字段见 《参数校验与全局异常处理》 第 6 题)、GET /api/users/{id}(id 必须 > 0)、DELETE /api/users/{id};提示 1(方向):第 4 题的关键是"这个请求需要 Service 返回什么"。
提示 2(关键点):第 6 题的参数化来源可以用 @MethodSource 提供"非法值 + 期望字段名"的二元组。
提示 3(接近答案):第 7 题的"复述打桩值"指的是——断言 amountInCents == 39800 而这个 39800 是你在 given(...) 里写下的。
@WebMvcTest 只加载 Web 层——@Service、@Repository、普通 @Component 都不在加载范围内。需要时用 @MockitoBean 补上 ✓
顺带:它会加载 @ControllerAdvice(所以全局异常处理器是生效的)✓
@MockitoBean(Spring Boot 3.4+);@MockBean。注意包路径:org.springframework.test.context.bean.override.mockito.MockitoBean(不在 org.springframework.boot.test.mock.mockito 了)。
| 维度 | MockMvc | TestRestTemplate |
|---|---|---|
| 网络 | ❌ 不经过 | ✅ 经过真实 TCP |
| Servlet 容器 | ❌ 不启动 | ✅ 真实 Tomcat |
| 路径拼接问题 | 不会暴露 | 会暴露(如 Location 头是否可直接访问) |
| 序列化/编码 | 基本等价 | 完整验证(含 HTTP 头、字符集协商) |
| 依赖的 Bean | 只有 Web 层 | 完整容器 |
| 速度 | 快 | 慢 |
一个具体的差异:MockMvc 里不会发现"应用没有配 server.port、容器起不来"这类问题 ✓
判断用法:想测"我的 Web 层代码"用 MockMvc;想测"整个应用跑起来对不对"用 TestRestTemplate ✓
第 4 题:不会通过。
原因:test2 请求 GET /api/products/999,但没有给 productService.find(999L) 打桩。
Mockito 对未打桩的方法返回默认值:
| 返回类型 | 默认值 |
|---|---|
对象(如 Product) | null |
int / long | 0 |
boolean | false |
Optional | Optional.empty() |
List / Set / Map | 空集合 |
所以:find(999L) 返回 null → Controller 拿到 null 之后取决于它怎么写:
| Controller 的写法 | 结果 |
|---|---|
return productService.find(id); | 返回 null → 200 + 空 body(不是 404)✗ |
Product p = productService.find(id); if (p == null) throw new NotFoundException(); ... | 404 ✓ |
所以这个题的关键不是"Mockito 默认返回什么",而是"Controller 是否自己处理 null" ✓
大概率不会通过——因为它返回了 200 而不是 404。
第 5 题:两种改法。
改法一:给 404 场景打桩(推荐)
@Test
void test2() throws Exception {
given(productService.find(999L))
.willThrow(new ResourceNotFoundException("商品", "999"));
mockMvc.perform(get("/api/products/999"))
.andExpect(status().isNotFound());
}改法二:确认 Controller 是否处理 null
// 如果 Controller 的约定是"Service 返回 null 表示不存在"
Product product = productService.find(id);
if (product == null) {
throw new ResourceNotFoundException("商品", String.valueOf(id));
}更好的做法是第一种:让 Service 的契约明确——"找不到就抛异常"(《参数校验与全局异常处理》 的 ResourceNotFoundException 就是这么设计的)✓
这样 Controller 里不需要判空,切片测试也只需打桩抛异常 ✓
顺带一个设计判断:"返回 null"与"抛异常"两种契约,前者容易忘判空(导致 NPE),后者由框架统一翻译成 404 ✓ 后者更好——这也是本项目的选择。
@WebMvcTest(OrderController.class)
@DisplayName("创建订单的参数校验:五条规则")
class CreateOrderValidationTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private OrderService orderService;
static Stream<Arguments> invalidRequests() {
return Stream.of(
Arguments.of("""
{"productId":"","quantity":1,"address":"上海"}""", "productId", "商品编号不能为空"),
Arguments.of("""
{"productId":" ","quantity":1,"address":"上海"}""", "productId", "商品编号不能为空"),
Arguments.of("""
{"productId":"P-1","quantity":0,"address":"上海"}""", "quantity", "数量必须大于 0"),
Arguments.of("""
{"productId":"P-1","quantity":101,"address":"上海"}""", "quantity", "单次最多购买 100 件"),
Arguments.of("""
{"productId":"P-1","quantity":1,"address":"%s"}""".formatted("长".repeat(201)),
"address", "收货地址长度不能超过 200")
);
}
@ParameterizedTest(name = "第 {index} 条规则:{1} 应当被拒绝")
@MethodSource("invalidRequests")
@DisplayName("非法请求一律 422,且 details 指出出错的字段")
void should_reject_invalid_request(String json, String expectedField, String expectedMessage) throws Exception {
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"))
.andExpect(jsonPath("$.details[0].field").value(expectedField))
.andExpect(jsonPath("$.details[0].message").value(expectedMessage));
// 核心断言:非法请求根本不该碰到业务逻辑
verifyNoInteractions(orderService);
}
}注意 productId = " " 那一条:它验证的是 @NotBlank 相对于 @NotNull 的价值(《参数校验与全局异常处理》第 5.2 节)——纯空白也算"没填" ✓
为什么"校验规则"适合参数化,"业务流程"不适合:
| 维度 | 校验规则 | 业务流程 |
|---|---|---|
| 输入输出关系 | "一个非法值 → 一条错误消息",纯映射 | 多步、有状态、有分支 |
| 用例数量 | 规则是有限的、可枚举的 | 流程组合爆炸,无法穷举 |
| 断言 | 都是"422 + 某字段某消息" | 每个流程的期望结果结构不同 |
| 测试价值 | 穷举边界(0 / 101 / 201 字符)价值高 | 穷举流程没意义,应覆盖关键路径 + 异常分支 |
| 可读性 | 一行数据一个用例,一目了然 | 参数化后没人看得懂在测什么 |
一句话:参数化测试适合"同一个断言模板 + 一组等价输入"——校验规则天生符合;业务流程不是。
顺带:这五条规则用参数化写只要 1 个方法,写成 5 个方法就要复制 5 遍 perform(...) 的样板代码 ✓ 这是参数化的直接收益(T1-002 讲过)。
应该用切片测试覆盖的场景(10 个):
| # | 场景 | 验证的框架行为 |
|---|---|---|
| 1 | 注册成功 → 201 + Location | @RequestBody 反序列化 + ResponseEntity.created |
| 2 | username 为空 → 422 | @NotBlank 触发 |
| 3 | password 太短 → 422 | @Size |
| 4 | password 不含数字 → 422 | @Pattern |
| 5 | email 格式错 → 422 | @Email |
| 6 | profile.nickname 为空 → 422 | 嵌套校验(@Valid 级联) |
| 7 | 多个字段同时错 → details 有多条 | Bean Validation 的错误收集机制 |
| 8 | 用户名已存在 → 409 | 业务异常 → 全局处理器 |
| 9 | GET /api/users/0 → 422 | @Validated 的方法级校验 |
| 10 | GET /api/users/abc → 400 | 类型转换失败 |
| 11 | 删除成功 → 204 | noContent() |
| 12 | 删除不存在 → 404 | ResourceNotFoundException → 全局处理器 |
切片测试覆盖不到、必须交给别的测试的:
| 场景 | 为什么覆盖不到 | 交给谁 |
|---|---|---|
| 密码真的被加密存储了 | Service 被 mock,存储不存在 | Service 单元测试 |
| 用户名唯一性在并发下是否成立 | 需要真实数据库 + 并发 | 集成测试 + 数据库唯一约束 |
| 注册后真的能登录(跨接口链路) | 单切片只测一个 Controller | 集成测试 |
| 返回的 JSON 里不该出现密码字段 | 切片测试(jsonPath("$.password").doesNotExist()) | |
| Tomcat 对超大请求体的拒绝 | 没有真实容器 | 集成测试 / 容器测试 |
注意第 4 行:"不该返回密码"这件事,切片测试反而特别适合——因为它只需要看响应 JSON ✓ 别把"覆盖不到"想得太宽。
3 个场景的实现:
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private UserService userService;
@Test
@DisplayName("嵌套字段错误:details 里的字段名带 profile. 前缀")
void should_report_nested_field_error() throws Exception {
mockMvc.perform(post("/api/users/register")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"username":"alice","password":"pass1234","email":"a@b.c","age":20,
"profile":{"nickname":"","city":"上海"}}
"""))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.details[0].field").value("profile.nickname"));
}
@Test
@DisplayName("用户名重复:Service 抛业务异常 → 409")
void should_return_409_when_username_duplicated() throws Exception {
given(userService.register(any()))
.willThrow(new BusinessException("USER_DUPLICATED", "用户名已被占用", HttpStatus.CONFLICT));
mockMvc.perform(post("/api/users/register")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"username":"alice","password":"pass1234","email":"a@b.c","age":20,
"profile":{"nickname":"Alice","city":"上海"}}
"""))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("USER_DUPLICATED"));
}
@Test
@DisplayName("响应里不能出现密码字段")
void should_not_expose_password() throws Exception {
given(userService.register(any())).willReturn(new UserResponse(1L, "alice", "a@b.c"));
mockMvc.perform(post("/api/users/register")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"username":"alice","password":"pass1234","email":"a@b.c","age":20,
"profile":{"nickname":"Alice","city":"上海"}}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.password").doesNotExist());
}
}区分"验证框架行为"与"复述打桩值":
| 断言 | 类型 | 原因 |
|---|---|---|
status().isUnprocessableEntity() | ✅ 框架行为 | 状态码由 Spring 的校验机制决定 |
jsonPath("$.details[0].field").value("profile.nickname") | ✅ 框架行为 | 字段路径由 Bean Validation 生成 |
jsonPath("$.code").value("VALIDATION_FAILED") | ⚠️ 一半 | 码是我们自己在 GlobalExceptionHandler 里写的 |
status().isConflict()(来自打桩的异常) | ✅ 框架行为 | "异常 → 状态码"的映射由处理器决定 |
jsonPath("$.id").value(1L) | ❌ 复述打桩值 | 1L 是 given(...) 里写下的 |
jsonPath("$.email").value("a@b.c") | ❌ 复述打桩值 | 同上 |
jsonPath("$.password").doesNotExist() | ✅ 契约 | 验证的是"DTO 里没这个字段" |
判断标准:
这个断言的期望值,是我在测试里"写死"的,还是由框架/契约"推导出来"的?
- 框架推导 → 有长期价值(改了框架会红,说明行为变了)
- 我写死的 → 低价值(它只能证明"mock 按我说的做了",T1-012 的自证预言)
但有一个例外:"字段不该存在"(如
password)是契约断言 ✓ 它验证的是"DTO 里没有这个字段",价值很高。
一条实践建议:切片测试里,"复述打桩值"的断言不必全删,但要清楚它没有保护力——真正保护你的是"结构断言"(字段存在性、字段名、状态码、错误码) ✓
@WebMvcTest 加载什么、不加载什么(尤其知道 @ControllerAdvice 会被加载);@MockitoBean(并知道它替代了 @MockBean、包路径变了);status() / header() / jsonPath() 三类断言,知道 $.array.length() 的写法;andDo(print()) 排查"路由没匹配上"与"响应结构不对";MockMvc 不经过网络与 Servlet 容器;《Spring 集成测试与 Testcontainers 概念》:让完整的应用真的跑起来,再测一遍。