本节目标:把单元、切片、集成、端到端四层测试的成本与收益摊开算账,给出可落地的配比与 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 依赖 |
MockitoTestExecutionListener | Mockito 的 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 真实依赖 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。