Spring Boot 测试策略:单元测试、切片测试与集成测试

20 阅读10分钟

1. 引言:测试不是为覆盖率而写

很多团队在项目后期被"覆盖率红线"倒逼着补测试:为了把 JaCoCo 的数字刷到 80%,写出一堆只调用方法、不断言结果的"形式测试"。这类测试有两个致命问题:

  • 没有断言价值:测试通过不代表行为正确,重构时也拦不住回归;
  • 维护成本高:一旦实现细节变动,大批测试集体失败,团队逐渐习惯"跳过测试"。

真正可持续的测试体系,目标不是覆盖率数字,而是交付信心:每次提交后,你能在几分钟内知道"这次改动有没有破坏既有行为"。Spring Boot 提供了从纯单元测试到完整集成测试的完整工具链,关键在于按测试金字塔分层使用,让大部分逻辑用最快的测试覆盖,只把必要的部分交给重量级测试。

本文围绕一条主线展开:如何在 Spring Boot 中把测试金字塔落地,并给出每一层的适用场景、代码模板与常见坑。

全文的阅读路径可以概括为下图:先建立分层认知,再逐层掌握工具与策略,最后落到 CI 与团队落地。

flowchart LR
    A["测试金字塔分层认知"] --> B["@SpringBootTest 与 WebEnvironment"]
    B --> C["切片测试选型"]
    C --> D["MockMvc 请求与断言"]
    D --> E["Mock 隔离外部依赖"]
    E --> F["Testcontainers 真实中间件"]
    F --> G["测试数据管理"]
    G --> H["CI 分层执行策略"]
    H --> I["团队落地路径"]

2. 测试金字塔在 Spring Boot 中的落地

测试金字塔的核心思想是:越底层的测试越多、越快、越稳定;越顶层的测试越少、越慢、越接近真实环境。

flowchart TD
    A[&#34;集成测试 @SpringBootTest + Testcontainers<br/>少量:验证装配与真实中间件交互&#34;] --> B[&#34;切片测试 @WebMvcTest / @DataJpaTest / @JsonTest<br/>适量:验证单层框架集成&#34;] 
    B --> C[&#34;单元测试 JUnit 5 + Mockito<br/>大量:验证业务逻辑与边界条件&#34;]

在 Spring Boot 项目中,三层可以这样对应:

层级典型技术启动内容单次耗时建议占比
单元测试JUnit 5、AssertJ、Mockito无 Spring 容器毫秒级60%~70%
切片测试@WebMvcTest、@DataJpaTest、@JsonTest仅相关自动配置百毫秒级20%~30%
集成测试@SpringBootTest、Testcontainers完整应用上下文秒级5%~10%

落地时的几条经验:

  • 业务规则优先下沉到单元测试:Service 中的计算、校验、状态流转,尽量设计成不依赖 Spring 容器的纯逻辑,用 Mockito 隔离 Repository 与外部客户端。
  • 框架集成用切片测试:Controller 的请求映射与参数绑定、Repository 的自定义 SQL、DTO 的序列化格式,这些"框架行为"用切片测试最划算。
  • 集成测试只验证"装配正确":跨层链路、事务边界、真实数据库方言差异、消息中间件连通性,这些是切片测试覆盖不到的。
  • 不要用 @SpringBootTest 写业务逻辑测试:启动整个上下文只为测一个 if 分支,是金字塔倒置的典型症状。

3. @SpringBootTest 的正确使用方式

@SpringBootTest 会启动完整的应用上下文,是最"重"的测试注解。它的关键参数是 webEnvironment,选错会带来不必要的启动开销或能力缺失。

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OrderApiIntegrationTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void shouldCreateOrder() {
        ResponseEntity<OrderVO> response = restTemplate.postForEntity(
                "/api/orders", new CreateOrderRequest("SKU-1", 2), OrderVO.class);
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(response.getBody().getQuantity()).isEqualTo(2);
    }
}

WebEnvironment 四种取值的差异:

取值是否启动内嵌容器端口适用场景
MOCK(默认)否,使用 Mock Servlet 环境无不需要真实 HTTP,配合 MockMvc 使用
RANDOM_PORT是随机需要真实 HTTP 调用,如 TestRestTemplate、WebTestClient
DEFINED_PORT是server.port需要固定端口,如与外部回调联调(易冲突,慎用)
NONE否无非 Web 应用,或只测 Service/Repository 装配

选择建议:

  • 只想验证 Bean 装配与业务链路,用 MOCK 或 NONE,启动最快;
  • 需要验证过滤器、拦截器、序列化、真实 HTTP 状态码,用 RANDOM_PORT;
  • 避免 DEFINED_PORT,并行执行时端口冲突会让 CI 变得不可靠。

另外两个高频优化点:

// 1. 复用上下文:相同配置的测试类共享同一个 ApplicationContext
@SpringBootTest
@ActiveProfiles("test")
class BaseIntegrationTest { }

// 2. 只加载需要的配置类,缩小上下文
@SpringBootTest(classes = {OrderService.class, OrderRepository.class})
class OrderServiceIntegrationTest { }

Spring TestContext 框架会按"配置组合"缓存上下文,只要注解组合、Profile、属性完全一致就复用。因此统一基类、避免在测试类上随意加 @MockBean(会破坏缓存键),能显著缩短整体测试时间。

4. 切片测试:@WebMvcTest、@DataJpaTest、@JsonTest

切片测试的本质是:只加载某一层相关的自动配置,其余 Bean 用 Mock 替代。它比单元测试更接近真实框架行为,又比完整集成测试轻得多。

三类切片测试的选型可以按"要验证什么"来决策:

flowchart TD
    A{&#34;要验证的内容&#34;} --> B[&#34;HTTP 路由、参数校验、异常处理&#34;]
    A --> C[&#34;SQL 查询、实体映射、事务行为&#34;]
    A --> D[&#34;JSON 序列化、日期格式、字段命名&#34;]
    B --> E[&#34;@WebMvcTest + MockMvc<br/>Service 用 @MockBean 替代&#34;]
    C --> F[&#34;@DataJpaTest<br/>内嵌数据库 + 事务回滚&#34;]
    D --> G[&#34;@JsonTest + JacksonTester<br/>只加载 Jackson 配置&#34;]
    C --> H{&#34;是否依赖生产库方言/函数&#34;}
    H -- 是 --> I[&#34;升级为 Testcontainers 集成测试&#34;]
    H -- 否 --> F

4.1 @WebMvcTest:只测 Web 层

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private OrderService orderService;

    @Test
    void shouldReturnOrder() throws Exception {
        given(orderService.findById(1L))
                .willReturn(new OrderVO(1L, "SKU-1", 2));

        mockMvc.perform(get("/api/orders/{id}", 1L))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.sku").value("SKU-1"))
                .andExpect(jsonPath("$.quantity").value(2));
    }
}

@WebMvcTest 只装配 @Controller、@ControllerAdvice、Filter、WebMvcConfigurer 等 Web 组件,不会加载 @Service、@Repository,因此依赖必须用 @MockBean 提供。它适合验证:路由映射、参数校验(@Valid)、异常处理器、统一响应包装。

4.2 @DataJpaTest:只测持久层

@DataJpaTest
class OrderRepositoryTest {

    @Autowired
    private TestEntityManager entityManager;

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void shouldFindByStatus() {
        entityManager.persist(new OrderEntity("SKU-1", OrderStatus.PAID));
        entityManager.flush();

        List<OrderEntity> orders = orderRepository.findByStatus(OrderStatus.PAID);

        assertThat(orders).hasSize(1);
    }
}

@DataJpaTest 默认使用内嵌数据库(H2 等)并开启事务回滚,适合验证派生查询方法、@Query 自定义 SQL、实体映射关系。注意:内嵌数据库与生产数据库(MySQL/PostgreSQL)在方言、函数、约束上存在差异,涉及复杂 SQL 时应升级为 Testcontainers 集成测试。

4.3 @JsonTest:只测序列化

@JsonTest
class OrderVOSerializationTest {

    @Autowired
    private JacksonTester<OrderVO> json;

    @Test
    void shouldSerializeWithIsoDate() throws Exception {
        OrderVO vo = new OrderVO(1L, "SKU-1", 2, LocalDateTime.of(2026, 1, 1, 10, 0));

        assertThat(json.write(vo))
                .hasJsonPathStringValue("$.createdAt", "2026-01-01T10:00:00");
    }
}

@JsonTest 只加载 Jackson 相关配置,适合验证日期格式、字段命名策略、@JsonIgnore、自定义 Serializer。这类测试极快,且能有效防止"接口字段悄悄改名"导致的上下游故障。

5. MockMvc:请求构造与响应断言

MockMvc 是 Web 层测试的核心工具,它不启动真实服务器,而是直接调用 DispatcherServlet,因此速度快且能完整走一遍 Spring MVC 处理链。

@WebMvcTest(OrderController.class)
class OrderControllerValidationTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private OrderService orderService;

    @Test
    void shouldRejectInvalidQuantity() throws Exception {
        mockMvc.perform(post("/api/orders")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"sku":"SKU-1","quantity":0}
                                """))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.code").value("INVALID_PARAM"))
                .andExpect(jsonPath("$.message").value("数量必须大于 0"));
    }
}

常用断言与构造技巧:

  • 请求构造:param() 传查询参数,content() 传请求体,header() 传请求头,cookie() 传 Cookie;
  • 状态与内容:status().isOk()、content().contentTypeCompatibleWith()、content().json();
  • JSON 断言:jsonPath("$.items[0].sku").value("SKU-1"),支持 exists()、doesNotExist()、isEmpty();
  • 路径变量:get("/api/orders/{id}", 1L) 会自动填充;
  • 文件上传:multipart("/api/files").file(new MockMultipartFile(...))。

一个容易忽略的点:MockMvc 默认不执行真实的过滤器链与安全配置,若项目使用 Spring Security,需要加 @AutoConfigureMockMvc(addFilters = true) 或使用 SecurityMockMvcRequestPostProcessors 才能验证鉴权逻辑。

一次 MockMvc 请求的完整处理链路如下,理解它有助于判断断言该写在哪一层:

sequenceDiagram
    participant T as &#34;测试方法&#34;
    participant M as &#34;MockMvc&#34;
    participant F as &#34;Filter 链&#34;
    participant D as &#34;DispatcherServlet&#34;
    participant C as &#34;Controller&#34;
    participant S as &#34;Mock 的 Service&#34;
    T->>M: &#34;perform(get/post ...)&#34;
    M->>F: &#34;进入过滤器链(默认不执行安全配置)&#34;
    F->>D: &#34;转发请求&#34;
    D->>C: &#34;路由匹配 + 参数绑定 + @Valid 校验&#34;
    C->>S: &#34;调用业务方法(返回预设值)&#34;
    S-->>C: &#34;OrderVO / 抛异常&#34;
    C-->>D: &#34;返回响应或交给 @ControllerAdvice&#34;
    D-->>M: &#34;MockHttpServletResponse&#34;
    M-->>T: &#34;andExpect 断言状态码与 JSON&#34;

6. @MockBean 与 Mockito:隔离外部依赖

@MockBean 用于替换 Spring 容器中的 Bean,而 Mockito 的 @Mock 用于创建普通对象。两者定位不同,混用是常见错误。

@SpringBootTest
class PaymentServiceTest {

    @MockBean
    private PaymentGatewayClient gatewayClient;   // 替换容器中的 Bean

    @Autowired
    private PaymentService paymentService;

    @Test
    void shouldMarkFailedWhenGatewayTimeout() {
        given(gatewayClient.charge(any()))
                .willThrow(new GatewayTimeoutException("timeout"));

        PaymentResult result = paymentService.pay(new PaymentCommand(1L, 100));

        assertThat(result.isSuccess()).isFalse();
        then(gatewayClient).should(times(1)).charge(any());
    }
}

实践要点:

  • 优先用构造器注入 + @Mock:纯单元测试不需要 Spring 容器,直接 new PaymentService(mockClient) 最快;
  • @MockBean 会破坏上下文缓存:每个不同的 @MockBean 组合都会创建新上下文,集成测试中滥用会让整体耗时翻倍;
  • @MockBean 已被标记为过时:Spring Boot 3.4 起推荐使用 @MockitoBean(Spring Framework 6.2 引入),新项目应直接采用;
  • 不要 Mock 一切:只隔离外部依赖(第三方 API、消息队列、时钟),对内部协作者过度 Mock 会让测试与实现强耦合;
  • 验证交互而非实现:verify() 用于确认"确实调用了外部系统一次",而不是确认内部方法调用顺序。

选择 Mock 手段时,可以按下面的边界判断,避免"Mock 一切"导致测试与实现强耦合:

flowchart TD
    A{&#34;被测对象依赖什么&#34;} --> B[&#34;纯业务逻辑 / 内部协作者&#34;]
    A --> C[&#34;Spring 容器中的 Bean&#34;]
    A --> D[&#34;第三方 API、消息队列、时钟&#34;]
    A --> E[&#34;数据库方言、Redis、Kafka 语义&#34;]
    B --> F[&#34;构造器注入 + @Mock<br/>纯单元测试,最快&#34;]
    C --> G[&#34;@MockitoBean(替代 @MockBean)<br/>注意会破坏上下文缓存&#34;]
    D --> H[&#34;Mock 隔离,验证交互次数&#34;]
    E --> I[&#34;Testcontainers 启动真实中间件&#34;]

7. Testcontainers:用真实中间件替代 Mock

当测试涉及数据库方言、事务隔离级别、Redis 命令语义、Kafka 消费位点时,Mock 无法给出可信结论。Testcontainers 通过 Docker 启动真实中间件,测试结束后自动销毁。

@SpringBootTest
@Testcontainers
class OrderRepositoryContainerTest {

    @Container
    static PostgreSQLContainer<?> postgres =
            new PostgreSQLContainer<>("postgres:16-alpine")
                    .withDatabaseName("demo")
                    .withUsername("test")
                    .withPassword("test");

    @DynamicPropertySource
    static void registerProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void shouldSupportJsonbQuery() {
        // 使用 PostgreSQL 特有的 jsonb 操作符,H2 无法验证
        assertThat(orderRepository.findByAttribute("channel", "app")).isNotEmpty();
    }
}

关键实践:

  • 容器声明为 static:同一测试类内所有方法共享一个容器,避免反复启动;
  • 用 @DynamicPropertySource 注入连接信息:不要硬编码端口;
  • 复用容器加速:在 ~/.testcontainers.properties 中开启 testcontainers.reuse.enable=true,本地开发时容器可跨次复用;
  • CI 中确保 Docker 可用:GitHub Actions 的 ubuntu-latest 自带 Docker,Jenkins 需挂载 Docker socket 或使用 DinD;
  • 镜像版本固定:使用 postgres:16-alpine 而非 postgres:latest,保证测试可重复。

8. 测试数据管理:@Sql 与事务回滚

测试数据是测试稳定性的隐形杀手。三条原则:每个测试自备数据、测试之间互不影响、数据构造可读。

8.1 事务回滚

@DataJpaTest 与 @SpringBootTest 默认在测试方法上开启事务,方法结束后回滚:

@SpringBootTest
@Transactional   // 默认回滚,测试之间互不污染
class OrderServiceTxTest {

    @Autowired
    private OrderService orderService;

    @Test
    void shouldDeductStock() {
        orderService.create(new CreateOrderRequest("SKU-1", 2));
        // 方法结束后自动回滚,无需手动清理
    }
}

注意:@Transactional 会让被测代码运行在同一个事务中,可能掩盖事务传播、延迟加载、唯一约束冲突等真实问题。因此集成测试中建议关闭测试事务,改用 @Sql 或 @BeforeEach 显式清理数据。

8.2 @Sql 准备数据

@SpringBootTest
@Sql(scripts = "/sql/order-fixture.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(scripts = "/sql/cleanup.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
class OrderQueryTest {

    @Autowired
    private OrderQueryService orderQueryService;

    @Test
    void shouldAggregateByStatus() {
        assertThat(orderQueryService.countByStatus()).containsEntry("PAID", 3);
    }
}

@Sql 支持类级与方法级叠加,executionPhase 可精确控制执行时机。相比在 Java 代码里堆砌 save(),SQL 脚本更直观、更易复用,也便于 DBA 参与评审。

8.3 数据构造的三种方式对比

方式优点缺点适用
@Sql 脚本直观、可复用、贴近真实数据与表结构强耦合集成测试、复杂关联数据
Builder / ObjectMother类型安全、IDE 友好关联对象多时冗长单元测试、切片测试
随机数据(如 Instancio)减少硬编码、易发现边界问题可读性下降、失败难复现属性测试、边界探索

测试数据的生命周期可以统一成一条流水线,保证测试之间互不污染:

flowchart LR
    A[&#34;BEFORE_TEST_METHOD<br/>@Sql 加载 fixture&#34;] --> B[&#34;执行测试方法&#34;]
    B --> C{&#34;是否开启测试事务&#34;}
    C -- 是 --> D[&#34;方法结束自动回滚&#34;]
    C -- 否 --> E[&#34;AFTER_TEST_METHOD<br/>@Sql 执行 cleanup&#34;]
    D --> F[&#34;下一个测试从干净状态开始&#34;]
    E --> F

9. CI 流水线中的测试执行策略

测试写得再好,如果 CI 里跑得慢、结果不稳定,团队依然会绕过它。CI 策略的核心是分层执行、快速反馈、失败可定位。

flowchart LR
    A[&#34;提交代码&#34;] --> B[&#34;编译 + 单元测试<br/>目标 < 2 分钟&#34;]
    B --> C{&#34;通过?&#34;}
    C -- 否 --> D[&#34;立即失败并通知&#34;]
    C -- 是 --> E[&#34;切片测试<br/>目标 < 5 分钟&#34;]
    E --> F[&#34;集成测试 + Testcontainers<br/>目标 < 15 分钟&#34;]
    F --> G[&#34;生成覆盖率报告<br/>合并到主分支&#34;]

具体建议:

  • 分阶段执行:单元测试作为第一道门禁,失败立即中断,避免浪费后续资源;
  • 按标签分组:用 JUnit 5 的 @Tag("integration") 标记重量级测试,Maven/Gradle 中通过 -Dgroups 或 -Pintegration 选择性执行;
  • 并行执行:Surefire 配置 forkCount 与 reuseForks,Gradle 开启 maxParallelForks,注意 DEFINED_PORT 与共享容器的冲突;
  • 覆盖率作为趋势而非门槛:关注"新增代码覆盖率"(如 JaCoCo 的 diff coverage),比全量覆盖率红线更有意义;
  • 失败可复现:CI 失败时保留测试报告与容器日志(withLogConsumer),避免"本地能过、CI 不过"的扯皮;
  • 定期清理:对长期 @Disabled 的测试设定复查期限,否则它们会变成"看起来有测试"的假象。

一个典型的 Maven 配置示例:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <configuration>
        <excludedGroups>integration</excludedGroups>
        <forkCount>1C</forkCount>
        <reuseForks>true</reuseForks>
    </configuration>
</plugin>
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-failsafe-plugin</artifactId>
    <configuration>
        <groups>integration</groups>
    </configuration>
    <executions>
        <execution>
            <goals>
                <goal>integration-test</goal>
                <goal>verify</goal>
            </goals>
        </execution>
    </executions>
</plugin>

10. 总结

建立可持续的测试体系,关键不在于"写多少测试",而在于每一层测试都承担明确的职责:

  • 单元测试守住业务逻辑与边界条件,快、多、稳定;
  • 切片测试守住框架集成细节,用最小的上下文验证 Web、持久化、序列化;
  • 集成测试守住装配正确性与真实中间件行为,少而精;
  • 数据与 CI 策略决定这套体系能否长期运转——自备数据、事务边界清晰、分层执行、快速反馈。

最后给团队一条可执行的落地路径:先梳理现有测试,把"启动整个上下文只为测一个分支"的用例下沉为单元测试;再为 Controller 与 Repository 补齐切片测试;最后只保留少量关键链路的 @SpringBootTest + Testcontainers 集成测试。覆盖率数字会自然上升,但更重要的是——每次提交后,你都敢直接发布。