Skip to content

Spring 切片测试与 MockMvc ​

T1-016必学高级约 90 分钟JDK 21测试专项 A:单元测试 · Spring 测试

1. 本节定位 ​

《Spring Boot 自动配置与项目结构》、《Spring MVC 请求映射与参数绑定》、《参数校验与全局异常处理》 三节下来,你已经有了一个能跑的 Web 应用。本节回答一个必然的问题:

这些接口该怎么测?

text
        端到端测试(真实浏览器 / 真实环境)
       ┌──────────────────────────────┐
       │      集成测试(@SpringBootTest)      ← 下一节
       ┌──────────────────────────────┐
       │    切片测试(@WebMvcTest)              ← 本节
       ┌──────────────────────────────┐
       │  单元测试(纯 JUnit + Mockito)          ← T1-001 ~ T1-012
       └──────────────────────────────┘

切片测试站在中间:它启动 Spring 容器,但只启动一小片——够测 HTTP 那一层,又不至于慢到没人愿意跑。

2. 学习目标 ​

完成本节后,你应该能够:

  • 说出测试的四个层次,以及切片测试站在哪一层、为什么;
  • 说出 @WebMvcTest 加载什么、不加载什么;
  • 说出 MockMvc 与"真实 HTTP 请求"的区别;
  • 用 @MockitoBean 替换被切片排除的依赖(并知道它替代了 @MockBean);
  • 用 status() / header() / jsonPath() 三类断言写测试;
  • 用 andDo(print()) 观察 MockMvc 处理的请求与响应详情;
  • 说出切片测试能覆盖什么、覆盖不到什么。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

先看"纯单元测试"测不到什么。

在 《Spring MVC 请求映射与参数绑定》 里,OrderController 的职责包括:

职责纯单元测试能测吗
调 Service、组装 ResponseEntity✅ 能
@GetMapping("/{orderNo}") 的路由对不对❌ 不能——那要 DispatcherServlet
@RequestBody 能不能正确反序列化❌ 不能——那要 HttpMessageConverter
@Valid 校验失败会不会返回 422❌ 不能——那要完整的参数解析器链
异常处理器会不会翻译成 404❌ 不能——那要 HandlerExceptionResolver

这些"框架帮我做的事",只有让框架真跑起来才测得到。

那为什么不用 @SpringBootTest 全都测(《Spring 集成测试与 Testcontainers 概念》)?因为慢——启动完整容器、连数据库、起网络端口,一套下来几十秒。

切片测试取中间:只加载 Web 那一层,于是:

text
路由、参数绑定、校验、异常处理  → 都测得到 ✓
数据库、Service、外部服务       → 用 mock 顶替(因为本来也不是这一层的事)

5. 核心概念 ​

5.1 测试的四个层次 ​

层次技术加载范围能测到什么
单元JUnit + Mockito无容器一个类的逻辑
切片@WebMvcTest / @DataJpaTest只加载一层该层的框架行为
集成@SpringBootTest完整容器跨层协作、真实依赖
端到端Selenium / Playwright真实部署的环境用户能感知的一切

切片(slice)这个词的含义:把应用"切"一片下来测——@WebMvcTest 切的是"Web 层"。

其他常见的切片注解:

注解切哪一片
@WebMvcTestWeb 层(Controller + MVC 基础设施)
@DataJpaTest数据层(Repository + 内嵌数据库)
@JsonTestJSON 序列化(只测 Jackson)
@RestClientTestREST 客户端

共同点:都只加载自己那一片的 Bean,其余用 mock 或排除 ✓

在测试金字塔里的位置(T1-005):

text
       /\        E2E        少  慢  贵
      /  \
     /────\      集成
    /      \
   /────────\    切片        ← 本节
  /          \
 /────────────\  单元       多  快  便宜

5.2 @WebMvcTest 加载什么 ​

java
@WebMvcTest(OrderController.class)          // ← 指定要测的 Controller
class OrderControllerSliceTest { }

它会加载:

类别具体
指定的 ControllerOrderController(写了参数时只加载它)
Spring MVC 基础设施DispatcherServlet、HandlerMapping、参数解析器链、消息转换器
@ControllerAdviceGlobalExceptionHandler ✓
@JsonComponent、Filter、WebMvcConfigurer与 Web 相关的组件
Converter / GenericConverter类型转换器

它不会加载:

类别具体后果
@ServiceOrderService必须用 @MockitoBean 提供
@Component / @Repository其他业务组件需要时手动 mock
普通 @Configuration 的 @Bean除了 Web 相关需要时 @Import
数据源、事务管理器自动配置被排除想测数据库要用 @DataJpaTest

这张表解释了切片测试最典型的报错:

text
No qualifying bean of type 'cn.kb.learn.web.service.OrderService' available

——Service 没被加载,你得自己给一个 ✓

一个值得注意的点:@ControllerAdvice 会被加载 ✓ 所以 《参数校验与全局异常处理》 写的 GlobalExceptionHandler 在切片测试里是生效的——这也是为什么"404 / 422 的响应体"能被测到。

如果 @RestControllerAdvice 不被加载,"异常 → 状态码"这条链路就成了切片测试的盲区——Spring 把它包含进来,正是为了让切片测试能覆盖"错误响应"这一层 ✓

5.3 MockMvc:不启动服务器的 HTTP 测试 ​

java
@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 做了什么:

text
真实环境:  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()

5.4 @MockitoBean:切片测试的"补位" ​

java
@WebMvcTest(OrderController.class)
class OrderControllerSliceTest {

    @Autowired
    private MockMvc mockMvc;

    /** Spring Boot 3.4 的新写法:替代已废弃的 @MockBean */
    @MockitoBean
    private OrderService orderService;
}

它做两件事:

  1. 创建一个 Mockito mock;
  2. 把它注册进 Spring 容器(替换掉原本会加载的 Bean)。

⚠️ 版本迁移提示:

Spring Boot 版本注解状态
≤ 3.3@MockBean / @SpyBean可用
3.4+@MockitoBean / @MockitoSpyBean推荐
未来@MockBean会被移除

包路径的变化(这是从旧代码迁移时最容易卡住的地方):

java
// 旧(已废弃)
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"是区分两者的关键 ✓

5.5 用 andDo(print()) 看清中间发生了什么 ​

这是写切片测试时最有用的工具:

java
mockMvc.perform(post("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""{"productId":"P-1","quantity":2,"address":"上海市浦东新区"}"""))
        .andDo(print())                                  // ← 打印请求、处理器、响应
        .andExpect(status().isCreated());

输出(真实运行结果):

text
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 下让控制台正确显示:

powershell
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
chcp 65001

5.6 切片测试能覆盖什么 ​

本节的 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 里的初始化逻辑)✓

6. 递进示例 ​

6.1 最小示例:测一个状态码 ​

java
@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)。

6.2 业务示例:完整的七个测试 ​

见第 7 节。

6.3 反例:把 Service 的逻辑也塞进切片测试 ​

java
@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)

判断标准:这个断言失败时,我应该去改哪一层的代码?

  • 改 Controller → 切片测试 ✓
  • 改 Service → Service 的单元测试 ✓
  • 改配置 / 接线 → 集成测试 ✓

7. 完整可运行代码 ​

文件:labs/spring-boot-web/src/test/java/cn/kb/learn/web/controller/OrderControllerSliceTest.java

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());
    }
}

运行命令:

powershell
cd labs
./mvnw -pl spring-boot-web test

预期输出:

text
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 19.69 s -- in cn.kb.learn.web.controller.OrderControllerSliceTest

七个测试各自的耗时(从 surefire XML 报告读出):

text
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)。别拿单次测量当基准——要看的是"成本花在哪里",而不是"到底几秒"。

8. Debug 观察点 ​

  1. 在 OrderController.create 的第一行打断点:观察 request 已经被反序列化好了——反序列化发生在方法调用之前(《Spring MVC 请求映射与参数绑定》第 5.1 节);
  2. 在校验失败的那个测试里打断点:这个断点永远不会命中(因为校验在进入方法前就失败了)——用"断点不命中"来证明"校验发生在 Controller 之前";
  3. 在 GlobalExceptionHandler.handleValidation 处打断点:观察它确实被切片测试加载了(虽然它不在 @WebMvcTest 的参数里);
  4. 观察 @MockitoBean 的效果:在 OrderService 的构造器上打断点——它不会被调用(因为那个 Bean 是 mock 出来的);
  5. 注释掉一个 @MockitoBean:观察报错 No qualifying bean of type 'OrderService'——这就是"切片不加载 @Service"的现场。

9. 常见错误 ​

错误 1:用了已废弃的 @MockBean ​

java
import org.springframework.boot.test.mock.mockito.MockBean;      // ✗ 3.4 起已废弃

@MockBean
private OrderService orderService;

症状:编译警告 @MockBean has been deprecated;未来版本会直接编译失败。

修复:换成 @MockitoBean,注意包路径变了:

java
import org.springframework.test.context.bean.override.mockito.MockitoBean;    // ✓

@MockitoBean
private OrderService orderService;

顺带:@SpyBean → @MockitoSpyBean(同样的迁移)✓

错误 2:@WebMvcTest 里注入 @Service 失败 ​

java
@WebMvcTest(OrderController.class)
class MyTest {
    @Autowired
    private OrderService orderService;      // ✗ 切片不加载 @Service
}

症状:

text
No qualifying bean of type 'cn.kb.learn.web.service.OrderService' available

两种修复:

想干什么怎么做
只想测 Web 层用 @MockitoBean 提供一个假的
真的需要真实 Service换 @SpringBootTest(那就不是切片测试了)

错误 3:@WebMvcTest 忘了指定 Controller ​

java
@WebMvcTest                                        // 不写参数
class MyTest { }

后果:加载所有 Controller——项目里 Controller 一多,上下文就变大、变慢,且可能因为别的 Controller 依赖缺失而启动失败 ✗

修复:始终写清要测哪个:

java
@WebMvcTest(OrderController.class)                  // ✓

这既是性能优化,也是"测试意图"的声明 ✓

错误 4:jsonPath 写错路径 ​

java
.andExpect(jsonPath("$.details").value(2));         // ✗ details 是数组,不是 2
.andExpect(jsonPath("$.details.length()").value(2));  // ✓ 要用 length()
.andExpect(jsonPath("$.data[0]").value(...));       // ✓ 数组元素用下标

症状:

text
No value at JSON path "$.details"

排查习惯:先用 andDo(print()) 把真实响应的 JSON 打出来,再对着它写 jsonPath ✓

错误 5:把 MockMvc 当成"端到端测试" ​

java
mockMvc.perform(get("/api/orders/ORD-1"))
        .andExpect(status().isOk());
// 就断言一个状态码,就以为"接口没问题"

问题:MockMvc 不经过网络,也不经过真实的 Service 与存储 ✗

这个测试证明的这个测试没有证明的
路由配对了真实数据库里能不能查到
Service 被调用了Service 返回的数据对不对
序列化没抛异常Tomcat 会不会因为请求体过大拒绝请求

修复:MockMvc 的断言要"重结构、轻数值"(结构来自框架,数值来自打桩);把"数值正确性"留给单元测试与集成测试 ✓

错误 6:以为"切片测试一定比集成测试快" ​

本轮的实测结果值得记下来:

text
切片测试(7 个): 13.62 s / 19.69 s(两次运行)
集成测试(4 个):  3.236 s /  7.438 s

切片测试反而更慢 ✗ 为什么?

原因说明
成本在"启动上下文",不在"加载多少 Bean"MVC 切片的自动配置筛选并不便宜
集成测试没有外部依赖本项目的存储是内存 Map——如果换成真实数据库,集成测试会慢得多
本机波动大同一份代码两次运行可以差 2 倍

正确的理解:

切片测试的价值不在"更快",而在"更聚焦": 加载的 Bean 少 → 测试间的相互影响小 → 失败时更好定位。

真正让集成测试变慢的是外部依赖(数据库、网络),不是"启动了完整的 Spring 容器"本身。

什么时候切片测试会明显更快:项目大、Bean 多、集成测试要连数据库的时候 ✓

10. 动手实验 ​

目标:亲手验证"切片测试覆盖了哪些框架行为"。

步骤:

  1. 运行第 7 节的命令,核对 7 个测试全绿;
  2. 故意破坏一个配置:把 spring-boot-web/pom.xml 里的 <parameters>true</parameters> 注释掉,重新运行——观察哪些测试失败、报什么错(提示:Name for argument not specified);
  3. 恢复后做"断点不命中"实验:在 OrderController.create 第一行打断点,单跑校验失败的那个测试——断点不会被命中;
  4. 制造"切片加载失败":把 @MockitoBean 注释掉,观察报错信息;
  5. 加一个断言:给"创建成功"的测试加 jsonPath("$.status").value("CREATED")(注意这个值来自打桩的 SAMPLE);
  6. 观察上下文启动成本:从 target/surefire-reports/TEST-*.xml 里读出每个测试方法的 time,核对"第一个测试远慢于其他";
  7. 跑两次,记录 Time elapsed 的波动——理解"单次测量不可靠"。

验收方式:能说出第 2 步的报错、第 3 步"断点不命中"的原因。

扩展任务:给 OrderController 加一个新接口(如 GET /api/orders/count?status=CREATED),先写切片测试再实现(T1-006 的测试先行)——体验"测试驱动地把路由、绑定、响应结构都定下来"。

11. 练习题 ​

基础题

  1. 判断:@WebMvcTest 会加载项目里所有的 @Service。(对 / 错)
  2. 填空:切片测试用 ______ 注解补上被排除的依赖;它替代了已废弃的 ______。
  3. 简答:MockMvc 与"用 TestRestTemplate 发真实请求"的核心区别是什么?

代码阅读题

java
@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());
    }
}
  1. test2 会通过吗?为什么?
  2. 如果要让 test2 通过,需要补什么?(给出代码)

编程题

  1. 为 《参数校验与全局异常处理》 的 CreateOrderRequest 写完整的校验切片测试:
    • 需求:验证五条校验规则各自的失败情况——productId 为空、productId 为纯空白、quantity = 0、quantity = 101、address 超过 200 字符;
    • 要求:
      • 用 @ParameterizedTest + @MethodSource 参数化(T1-002);
      • 每个用例断言状态码 422、code 是 VALIDATION_FAILED、details 里包含对应的字段名;
      • 额外断言:Service 一次都没被调用(verifyNoInteractions);
    • 写完后回答:为什么"校验规则"适合用参数化测试写,而"业务流程"不适合?

综合任务

  1. 为一个"用户注册"接口设计完整的切片测试方案:
    • 接口:POST /api/users/register(字段见 《参数校验与全局异常处理》 第 6 题)、GET /api/users/{id}(id 必须 > 0)、DELETE /api/users/{id};
    • 要求:
      • 列出应该用切片测试覆盖的场景(至少 8 个),并说明每个场景验证的是"哪一层的框架行为";
      • 列出切片测试覆盖不到、必须交给其他测试的场景(至少 3 个),并说明该交给谁;
      • 实现其中 3 个场景的测试代码;
      • 额外要求:说明在这个测试类里,哪些断言是"验证框架行为"(有长期价值),哪些是"复述打桩值"(价值低,甚至有害)。
分级提示

提示 1(方向):第 4 题的关键是"这个请求需要 Service 返回什么"。

提示 2(关键点):第 6 题的参数化来源可以用 @MethodSource 提供"非法值 + 期望字段名"的二元组。

提示 3(接近答案):第 7 题的"复述打桩值"指的是——断言 amountInCents == 39800 而这个 39800 是你在 given(...) 里写下的。

12. 参考答案 ​

基础题
  1. 错。@WebMvcTest 只加载 Web 层——@Service、@Repository、普通 @Component 都不在加载范围内。

需要时用 @MockitoBean 补上 ✓

顺带:它会加载 @ControllerAdvice(所以全局异常处理器是生效的)✓

  1. @MockitoBean(Spring Boot 3.4+);@MockBean。

注意包路径:org.springframework.test.context.bean.override.mockito.MockitoBean(不在 org.springframework.boot.test.mock.mockito 了)。

  1. 核心区别是"走不走真实的 HTTP 与容器":
维度MockMvcTestRestTemplate
网络❌ 不经过✅ 经过真实 TCP
Servlet 容器❌ 不启动✅ 真实 Tomcat
路径拼接问题不会暴露会暴露(如 Location 头是否可直接访问)
序列化/编码基本等价完整验证(含 HTTP 头、字符集协商)
依赖的 Bean只有 Web 层完整容器
速度快慢

一个具体的差异:MockMvc 里不会发现"应用没有配 server.port、容器起不来"这类问题 ✓

判断用法:想测"我的 Web 层代码"用 MockMvc;想测"整个应用跑起来对不对"用 TestRestTemplate ✓

代码阅读题(第 4、5 题)

第 4 题:不会通过。

原因:test2 请求 GET /api/products/999,但没有给 productService.find(999L) 打桩。

Mockito 对未打桩的方法返回默认值:

返回类型默认值
对象(如 Product)null
int / long0
booleanfalse
OptionalOptional.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 场景打桩(推荐)

java
@Test
void test2() throws Exception {
    given(productService.find(999L))
            .willThrow(new ResourceNotFoundException("商品", "999"));

    mockMvc.perform(get("/api/products/999"))
            .andExpect(status().isNotFound());
}

改法二:确认 Controller 是否处理 null

java
// 如果 Controller 的约定是"Service 返回 null 表示不存在"
Product product = productService.find(id);
if (product == null) {
    throw new ResourceNotFoundException("商品", String.valueOf(id));
}

更好的做法是第一种:让 Service 的契约明确——"找不到就抛异常"(《参数校验与全局异常处理》 的 ResourceNotFoundException 就是这么设计的)✓

这样 Controller 里不需要判空,切片测试也只需打桩抛异常 ✓

顺带一个设计判断:"返回 null"与"抛异常"两种契约,前者容易忘判空(导致 NPE),后者由框架统一翻译成 404 ✓ 后者更好——这也是本项目的选择。

编程题(第 6 题)
java
@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 讲过)。

综合任务(第 7 题)

应该用切片测试覆盖的场景(10 个):

#场景验证的框架行为
1注册成功 → 201 + Location@RequestBody 反序列化 + ResponseEntity.created
2username 为空 → 422@NotBlank 触发
3password 太短 → 422@Size
4password 不含数字 → 422@Pattern
5email 格式错 → 422@Email
6profile.nickname 为空 → 422嵌套校验(@Valid 级联)
7多个字段同时错 → details 有多条Bean Validation 的错误收集机制
8用户名已存在 → 409业务异常 → 全局处理器
9GET /api/users/0 → 422@Validated 的方法级校验
10GET /api/users/abc → 400类型转换失败
11删除成功 → 204noContent()
12删除不存在 → 404ResourceNotFoundException → 全局处理器

切片测试覆盖不到、必须交给别的测试的:

场景为什么覆盖不到交给谁
密码真的被加密存储了Service 被 mock,存储不存在Service 单元测试
用户名唯一性在并发下是否成立需要真实数据库 + 并发集成测试 + 数据库唯一约束
注册后真的能登录(跨接口链路)单切片只测一个 Controller集成测试
返回的 JSON 里不该出现密码字段切片测试其实能测 ✓切片测试(jsonPath("$.password").doesNotExist())
Tomcat 对超大请求体的拒绝没有真实容器集成测试 / 容器测试

注意第 4 行:"不该返回密码"这件事,切片测试反而特别适合——因为它只需要看响应 JSON ✓ 别把"覆盖不到"想得太宽。

3 个场景的实现:

java
@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 里没有这个字段",价值很高。

一条实践建议:切片测试里,"复述打桩值"的断言不必全删,但要清楚它没有保护力——真正保护你的是"结构断言"(字段存在性、字段名、状态码、错误码) ✓

13. 掌握标准 ​

  • [ ] 能说出测试的四个层次与切片测试的位置;
  • [ ] 能说出 @WebMvcTest 加载什么、不加载什么(尤其知道 @ControllerAdvice 会被加载);
  • [ ] 会用 @MockitoBean(并知道它替代了 @MockBean、包路径变了);
  • [ ] 会用 status() / header() / jsonPath() 三类断言,知道 $.array.length() 的写法;
  • [ ] 会用 andDo(print()) 排查"路由没匹配上"与"响应结构不对";
  • [ ] 知道 MockMvc 不经过网络与 Servlet 容器;
  • [ ] 知道切片测试的成本在"上下文启动",知道它不一定比集成测试快;
  • [ ] 能区分"验证框架行为的断言"与"复述打桩值的断言"。

14. 扩展资源 ​

15. 下一步学习 ​

《Spring 集成测试与 Testcontainers 概念》:让完整的应用真的跑起来,再测一遍。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引