《Spring Boot 实战》4.1 测试分层策略

本节用图书借阅服务的借阅流程,把单元、切片、集成、端到端四层测试的成本与收益逐项拆开,给出各层配比与 CI 分阶段执行命令,并对齐 Spring Boot 4.x 的测试注解口径(@MockitoBean、RestTestClient、@AutoConfigureMockMvc),最后讲清测试并行与上下文缓存的取舍。

本节目标:把单元、切片、集成、端到端四层测试的成本与收益摊开算账,给出可落地的配比与 CI 分层命令,并把 4.x 的测试注解口径一次对齐。
适用版本:Spring Boot 4.1.x(Java 21)

4.1 测试分层策略

本书从这一章起进入工程化环节。前面三章解决的是「代码怎么写、配置怎么管」,这一章解决的是「你怎么知道自己写的代码是对的,而且明天、下个迭代还一直对」。我们仍然用那套「图书借阅管理服务」:它有一张 book、一张 member、一张 loan,核心用例是借书与还书。业务规则不复杂——库存为 0 不能借、被停用的会员不能借、每名会员同时最多借 5 本——但正是这些规则最容易在重构中被悄悄改坏。

入门卷讲过 spring-boot-starter-test 里有什么。本节不重复「某个注解能做什么」,而是回答一个更贵的问题:这四层测试各写多少、跑多快、什么时候跑、代价是什么。

4.1.1 先把四层定义清楚

分层不是仪式,而是对「启动成本」与「定位成本」的权衡。同一个借书用例,在不同层里验证的是完全不同的东西:

层加载了什么借书场景里验证什么单条耗时量级
单元只有普通 Java 对象库存扣减、上限判断、逾期计算毫秒级
切片一层 Spring 上下文(Web 或 JPA)参数绑定、校验、序列化、SQL 映射百毫秒级
集成完整应用 + 真实中间件借书→还书端到端状态流转秒级
端到端部署后的整套系统跨服务链路与用户旅程十秒级以上

耗时给的是量级,不是精确值:它取决于机器、上下文是否命中缓存、中间件是否就绪。真正要记住的是相邻两层之间往往差一个数量级,而「差一个数量级」直接决定了它能不能进快速反馈回路。

4.1.2 成本收益表:把配比算出来

「测试金字塔」这句口号没有可操作性。下表把每层的成本项摊开,你可以按自己的项目填空:

维度单元切片集成端到端
单条执行成本极低低中高
上下文启动无每套配置一次每套配置一次每次部署
失败定位精确到方法精确到层需看日志与数据跨系统排查
假阳性风险低中(mock 与真实行为偏差)低低
假阴性风险高(漏真实装配问题)中低低
维护成本随重构变动随接口变动随依赖变动随环境变动
建议占比约 60%约 25%约 12%约 3%

比例只是起点。判断标准是:任何一条测试,问它「如果它红了,我要花多久定位、要花多久修」,如果修复成本远高于它拦下的缺陷价值,这条测试就该降级或删掉。生产上常见的失衡有两种:全站只有端到端测试(改一行接口全红,定位靠猜),或者全是单元测试(装配、事务、序列化问题全部漏到线上)。

4.1.3 单元测试:不碰 Spring,越快越好

借阅规则最适合单元测试。LoanService 依赖两个仓储和一个时钟,我们把时钟注入进来,避免测试依赖系统时间:

public class Book {
    private Long id;
    private String isbn;
    private String title;
    private int totalCopies;
    private int availableCopies;

    public void borrowOne() {
        if (availableCopies <= 0) {
            throw new NoAvailableCopyException(isbn);
        }
        availableCopies--;
    }

    public void returnOne() {
        if (availableCopies < totalCopies) {
            availableCopies++;
        }
    }
    // getter / setter 省略
}

测试用纯 Mockito,不启动任何 Spring 上下文:

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.BDDMockito.given;

@ExtendWith(MockitoExtension.class)
class LoanServiceTest {

    @Mock
    private BookRepository bookRepository;

    @Mock
    private LoanRepository loanRepository;

    private final Clock clock =
            Clock.fixed(Instant.parse("2026-09-01T02:00:00Z"), ZoneOffset.UTC);

    private LoanService loanService;

    @BeforeEach
    void setUp() {
        loanService = new LoanService(bookRepository, loanRepository, clock);
    }

    @Test
    void borrow_decrementsAvailableCopies() {
        Book book = new Book(1L, "978-7-111-40701-0", "Effective Java", 3, 3);
        given(bookRepository.findById(1L)).willReturn(Optional.of(book));
        given(loanRepository.countByMemberIdAndReturnedAtIsNull(7L)).willReturn(0L);

        Loan loan = loanService.borrow(1L, 7L);

        assertThat(book.getAvailableCopies()).isEqualTo(2);
        assertThat(loan.getDueAt()).isEqualTo(Instant.parse("2026-09-15T02:00:00Z"));
    }

    @Test
    void borrow_rejectsWhenNoCopyLeft() {
        Book book = new Book(1L, "978-7-111-40701-0", "Effective Java", 1, 0);
        given(bookRepository.findById(1L)).willReturn(Optional.of(book));

        assertThatThrownBy(() -> loanService.borrow(1L, 7L))
                .isInstanceOf(NoAvailableCopyException.class);
    }
}

注意 MockitoExtension:4.x 里 Spring Boot 的 MockitoTestExecutionListener 已被移除,@Mock / @Captor 字段必须靠 Mockito 自己的扩展来驱动。这是 3.x 升级到 4.x 时最容易「测试静默失效」的一处——注解还在,只是不再生效。

4.1.4 切片测试:只加载需要的那一层

切片测试的价值在于「真的过一遍 HTTP 或 SQL,但不启动整台机器」。Web 层切片用 @WebMvcTest,它只装配 MVC 相关组件,把 @Service / @Repository 全部挡在外面:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@WebMvcTest(LoanController.class)
class LoanControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private LoanService loanService;

    @Test
    void borrow_returns201WithLocation() throws Exception {
        given(loanService.borrow(1L, 7L)).willReturn(
                new Loan(100L, 1L, 7L,
                        Instant.parse("2026-09-01T02:00:00Z"),
                        Instant.parse("2026-09-15T02:00:00Z")));

        mockMvc.perform(post("/api/loans")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"bookId\":1,\"memberId\":7}"))
                .andExpect(status().isCreated())
                .andExpect(header().string("Location", "/api/loans/100"))
                .andExpect(jsonPath("$.loanId").value(100));
    }
}

@WebMvcTest 自带 MockMvc,不需要额外加 @AutoConfigureMockMvc。这点和 @SpringBootTest 不同,见下一小节。

JPA 层切片用 @DataJpaTest,它会装配实体管理器与 Spring Data 仓储。它默认把数据源替换成内嵌数据库——这个默认值在 4.x 依然存在,也是下一节要重点批判的对象:H2 与 PostgreSQL 的方言差异会让「切片全绿、集成全红」。要关掉替换需要显式加 @AutoConfigureTestDatabase(replace = Replace.NONE) 并指向真实数据源。

4.1.5 集成测试:用真实 HTTP 打一遍

集成测试要的是「装配是否正确、事务是否生效、序列化是否对得上」。4.0 起 @SpringBootTest 不再自动提供 MockMvc、WebClient、TestRestTemplate,你需要显式声明:

想用4.0 起需要额外加
MockMvc@AutoConfigureMockMvc
TestRestTemplate@AutoConfigureTestRestTemplate
RestTestClient@AutoConfigureRestTestClient

RestTestClient 是 4.0 新增的测试客户端,既能驱动 MockMvc,也能打真实端口,写法比 TestRestTemplate 更贴近 WebTestClient:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
class LoanApiIntegrationTest {

    @Autowired
    private RestTestClient restTestClient;

    @Test
    void borrow_thenReturn_restoresAvailability() {
        restTestClient.post().uri("/api/loans")
                .contentType(MediaType.APPLICATION_JSON)
                .body(new BorrowRequest(1L, 7L))
                .exchange()
                .expectStatus().isCreated()
                .expectBody()
                .jsonPath("$.loanId").isNumber();

        restTestClient.post().uri("/api/loans/{id}/return", 100L)
                .exchange()
                .expectStatus().isOk();
    }
}

@AutoConfigureMockMvc / @AutoConfigureRestTestClient 这些自动配置注解在 4.0 的模块化重构中随所属模块搬过包。写代码时以 IDE 的自动导入为准,不要照抄 3.x 教程里的 org.springframework.boot.test.autoconfigure.* 全路径。

4.1.6 注解口径对照(3.5 → 4.x)

3.5.x 写法4.x 写法备注
@MockBean@MockitoBean旧注解已移除;新注解不能用在 @Configuration 类里
@SpyBean@MockitoSpyBean同上
@SpringBootTest 自带 MockMvc加 @AutoConfigureMockMvc不再隐式提供
@SpringBootTest 自带 TestRestTemplate加 @AutoConfigureTestRestTemplate还需 spring-boot-resttestclient 依赖
MockitoTestExecutionListenerMockito 的 MockitoExtension监听器已移除
@PropertyMapping(test.autoconfigure.properties)org.springframework.boot.test.context包已迁移

@MockitoBean 不能用在一个 @Configuration 类里,这意味着 3.x 那种「在一个 @TestConfiguration 里集中声明一批 mock」的写法失效了。替代做法是把 @MockitoBean 直接标在测试类上(可批量),或抽一个自定义注解:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@MockitoBean(types = {LoanService.class, BookService.class})
public @interface SharedMocks {
}

4.1.7 CI 里怎么分层执行

分层真正的收益在 CI 里兑现。目标很明确:推送时只跑快的,合并前跑全的,夜里跑最贵的。Maven 用 surefire(*Test)跑单元与切片、failsafe(*IT)跑集成与端到端:

# 阶段一:每次 push,只跑单元 + 切片,目标 3 分钟内
./mvnw -B test

# 阶段二:PR 合并前,追加集成测试
./mvnw -B verify

# 阶段三:夜间 / 发布前,跑全量含端到端
./mvnw -B verify -Pit-full

用 JUnit 5 的 @Tag 把集成用例单独标记,比靠文件名约定更可靠:

@Tag("integration")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class LoanApiIntegrationTest { /* ... */ }
# 只跑打了 integration 标签的用例
./mvnw -B test -Dgroups=integration
# 跑除了 integration 之外的全部
./mvnw -B test -DexcludedGroups=integration

4.1 有一处容易踩的坑:-DskipTests 不再跳过测试的 AOT 处理。如果你的流水线用 -DskipTests 想「先编出来再说」,需要改成 -Dmaven.test.skip=true 才能真的把测试相关处理一起跳过。

4.1.8 测试并行与上下文缓存:一对矛盾

Spring TestContext 会把「配置相同的测试类」复用同一个应用上下文。缓存键由配置类集合、属性源、激活的 profile、ContextCustomizer 等共同决定——所以下面这些操作都会各自生成一份新上下文:

  • 每个测试类用不同的 @MockitoBean 组合;
  • 每个测试类用不同的 @TestPropertySource;
  • 到处撒 @DirtiesContext。

上下文数量直接决定集成测试的总耗时:命中缓存时每条测试是毫秒级,未命中时要重新启动(4.1.1 实测单次启动约 0.8–1.1 秒,但加上依赖初始化与容器就远不止)。

开启 JUnit 5 并行能压榨多核,但要先想清楚两件事:

# src/test/resources/junit-platform.properties
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.mode.default=same_thread
junit.jupiter.execution.parallel.mode.classes.default=concurrent

第一,并行会稀释上下文缓存:多个类同时抢同一个上下文,Spring 会在需要时同步等待,收益未必线性。第二,并行要求测试之间没有共享可变状态:同一个数据库里的行、同一个静态容器、同一个内存缓存都会互相污染。稳妥的顺序是先把测试做成可并行的(数据隔离,见 4.3),再打开并行。

4.1.9 常见坑

  • 用 @MockBean 从 3.x 复制过来:4.x 里它已被移除,编译期就会报错,不要靠改包名绕过。
  • 以为 @SpringBootTest 还能直接 @Autowired MockMvc:4.x 会注入失败,必须加 @AutoConfigureMockMvc。
  • 切片测试用内嵌数据库,集成测试用 PostgreSQL,两边方言不一致导致「切片绿、集成红」,下一节专门处理。
  • 把端到端测试当成回归主力:改一个字段名全红,定位成本远高于它拦下的缺陷。
  • 到处 @DirtiesContext:每条测试重建上下文,集成测试耗时成倍增长。

4.1.10 小结

  • 四层的差异本质是「启动成本」与「定位成本」的交换,建议配比 60/25/12/3,但要以「红了多久能修好」为最终判据。
  • 单元测试用 MockitoExtension 驱动 @Mock;4.x 已移除 MockitoTestExecutionListener。
  • 4.x 的 mock 注解是 @MockitoBean / @MockitoSpyBean,且不能写在 @Configuration 里。
  • @SpringBootTest 不再自带 MockMvc 与 TestRestTemplate,要按需加 @AutoConfigureMockMvc / @AutoConfigureTestRestTemplate;新代码优先用 RestTestClient。
  • CI 分层:push 跑单元 + 切片,合并前加集成,夜间跑全量;-DskipTests 在 4.1 不再跳过 AOT,改用 -Dmaven.test.skip。
  • 上下文缓存与并行是一对矛盾,先做数据隔离再开并行。

分层解决了「什么时候跑、跑多快」,但切片测试默认用 H2 替代真实数据库,这会在最不该出问题的地方埋雷。下一节我们用 Testcontainers 把真实依赖搬进测试。

阅读导航:上一节:3.3 配置中心 · 下一节:4.2 Testcontainers 真实依赖 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计