本节目标:理解测试金字塔与「单元测试不该启动 Spring 容器」这条原则,掌握 JUnit 5 的注解与断言,以及 Mockito 打桩与校验的完整用法,为图书服务写出一组可运行的单元测试。
适用版本:Spring Boot 4.1.x(Java 21)
17.1 单元测试
前面 16 章我们把图书服务从零搭了起来:实体、Repository、Service、Controller、异常、配置一应俱全。但验证行为的方式一直是「启动应用,用浏览器或 curl 手动点一遍」。这一章把它补成真正的测试。
本章按测试金字塔自下而上走三层:17.1 单元测试(不启动容器)、17.2 切片测试(只启动一层)、17.3 集成测试(启动完整容器)。三节统一围绕同一个图书服务展开,方便对照。
17.1.1 测试金字塔:单元测试为什么不该启动 Spring 容器
把测试按粒度和数量排成一座金字塔:
| 层级 | 数量 | 单次耗时 | 启动 Spring 容器 | 隔离度 | 典型工具 |
|---|---|---|---|---|---|
| 单元测试 | 最多 | 毫秒级 | 否 | 高,只测一个类 | JUnit 5 + Mockito |
| 切片测试 | 中等 | 百毫秒~秒级 | 只加载一层 | 中 | @WebMvcTest / @DataJpaTest |
| 集成测试 | 最少 | 数秒 | 是,完整上下文 | 低,端到端 | @SpringBootTest |
金字塔的形态由成本决定:越靠上的测试,单次执行越慢、越难定位失败、越容易受环境影响。所以「多写单元测试」不是教条,而是性价比最高的选择。
单元测试的定义:只验证一个类(一个单元)的行为,它依赖的协作者(数据库、HTTP 客户端、其他 Service)全部用测试替身替换掉。既然是替身,就不需要真数据库连接,自然也不需要启动 Spring 容器来创建这些 Bean。
对照两种做法测同一个 BookService.getById:走 @SpringBootTest 要先让 Spring 加载上下文、扫描 Bean、建 DataSource 与 EntityManagerFactory、再建 Tomcat(第 3 章实测约 0.8–1.1 秒),之后才轮到你的断言;而纯单元测试只是 new BookService(mockRepository) 然后断言,约 5 毫秒。
一个 Service 类可能有 10 个方法、20 条分支。用单元测试,20 条分支总共几十毫秒;用集成测试,每条分支都要付一次上下文启动成本。单元测试的收益,一半来自它跑得快。
17.1.2 spring-boot-starter-test 里到底有什么
spring-boot-starter-test 是一个 test 作用域的聚合依赖,一次性带来下面这些库:
| 库 | 作用 | 本章用到 |
|---|---|---|
| JUnit 5(Jupiter) | 测试框架与断言 | 全程 |
| Mockito | 打桩与校验的替身框架 | 17.1.6 起 |
| AssertJ | 流式断言(assertThat) | 示例中少量使用 |
| Hamcrest | 匹配器库,供 assertThat 旧式写法 | 了解即可 |
| JsonPath | 校验 JSON 响应字段(jsonPath) | 17.2 |
| JSONassert / Awaitility / XMLUnit | JSON 比较 / 异步轮询 / XML 比较 | 了解即可 |
引入方式:在 pom.xml 里加一个 spring-boot-starter-test 依赖,<scope>test</scope>,无需写版本号(版本由父 POM 管理)。
要记住:单元测试里并不需要这个 starter 的全部。JUnit 5 与 Mockito 才是主角,其余库在切片与集成测试里才登场。不要因为 starter 里有 JsonPath,就在单元测试里做 JSON 断言——那是 17.2 的事。
17.1.3 JUnit 5 核心注解
| 注解 | 作用 |
|---|---|
@Test | 标记一个测试方法 |
@BeforeEach / @AfterEach | 每个测试方法前后各执行一次 |
@BeforeAll / @AfterAll | 整个测试类前后各执行一次(方法须为 static) |
@DisplayName | 给测试类或方法起可读名字,支持中文 |
@Disabled | 暂时跳过某个测试(附上原因) |
@Nested | 定义内嵌测试类,把相关用例分组 |
@ParameterizedTest | 参数化测试,一次覆盖多组输入 |
@BeforeEach 常用来准备被测对象和桩数据,@AfterEach 用来清理临时文件、关闭资源。JUnit 5 默认为每个测试方法创建新的测试类实例,字段不会在方法之间串味,@BeforeEach 里重新赋值即可。
参数化测试用 @ParameterizedTest 加数据源避免复制粘贴:@ValueSource(strings = {"Joshua Bloch", "Martin Fowler"}) 供单参数,@CsvSource({ "2018, true", "1500, false" }) 供多参数,测试方法把每组数据声明成形参,运行时会逐组执行并单独报告结果。
17.1.4 断言
JUnit 5 的断言在 org.junit.jupiter.api.Assertions 里,用静态导入(import static org.junit.jupiter.api.Assertions.*;)。三个最常用的:
@Test
void assertions() {
// 相等断言:期望值在前,实际值在后(顺序反了报错信息会误导人)
assertEquals("Effective Java", book.getTitle());
// 断言抛异常:返回捕获到的异常,可继续断言它的属性
BookNotFoundException ex = assertThrows(
BookNotFoundException.class,
() -> bookService.getById(999L));
assertEquals(999L, ex.getId());
// 一组断言一起执行:任一失败都不中断其余断言,最后汇总报告
assertAll("book",
() -> assertEquals("Effective Java", book.getTitle()),
() -> assertEquals("Joshua Bloch", book.getAuthor()),
() -> assertEquals(2018, book.getPublishedYear()));
}
assertAll 的价值在于「一次跑完所有断言」。普通 assertEquals 在第一条失败后就停下,你只能一轮一轮地修;用 assertAll 一次拿到全部不匹配项。对同一对象的多个字段做校验时优先用它。
17.1.5 测试命名与结构
结构:given-when-then(Arrange-Act-Assert),每个测试方法切成三段:先准备输入与桩(given),再调用被测方法、且只在这一段调用一次(when),最后断言结果(then)。三段之间用空行和注释隔开,读测试的人一眼能分清「准备」与「断言」。
命名:方法名_条件_期望结果,例如 create_duplicateTitle_throws、findByAuthor_noMatch_returnsEmptyList。再叠加 @DisplayName 写一句人话,运行报告里会直接显示它。
17.1.6 Mockito:替身三件套
单元测试的难点不是断言,而是把依赖换掉。Mockito 的核心就三个动作:造替身、打桩、校验。
| 注解/方法 | 作用 |
|---|---|
@Mock | 造一个替身对象,未打桩的方法返回默认值(对象 null、int 0) |
@InjectMocks | 造被测对象,并把上面的 @Mock 注入进去(优先构造器注入) |
when(...).thenReturn(...) | 打桩:指定某方法在某入参下返回什么 |
when(...).thenThrow(...) | 打桩:指定某方法抛出异常 |
verify(...) | 校验某方法是否被调用、几次、用什么参数 |
ArgumentCaptor | 捕获方法实际收到的参数,用于深入断言 |
when(bookRepository.findByTitle("Effective Java"))
.thenReturn(Optional.of(existingBook));
verify(bookRepository).save(any(Book.class));
verify(bookRepository, never()).delete(any(Book.class));
never() 表示「从未调用」,常用于校验「异常路径下不应该写库」。还有 times(n)、atLeastOnce()、atMost(n) 等调用次数限定。
ArgumentCaptor 用来断言「传进去的参数」:ArgumentCaptor.forClass(Book.class) 建捕获器,verify(...).save(captor.capture()) 在调用发生处抓取实参,再 captor.getValue() 取回对象做字段断言。当返回值被忽略、而你关心它究竟收到了什么时,它是唯一的办法。
17.1.7 4.x 下集成 Mockito 的正确方式
要让 @Mock / @InjectMocks 生效,必须把 Mockito 注册为 JUnit 5 扩展。4.x 的正确写法是加 @ExtendWith(MockitoExtension.class):
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class BookServiceTest {
// ...
}
两点要特别注意:
- Spring Boot 3.x 时代那套基于
MockitoTestExecutionListener的自动初始化已经在 4.x 移除。不要再依赖「什么都不加,@Mock也能被填充」的旧行为,显式声明扩展才是 4.x 的方式。 MockitoExtension默认开启严格打桩(strict stubs):打了一个桩却从未用到,会报UnnecessaryStubbingException。这不是 bug,而是提醒你「这条桩已经和被测行为脱节」。确有需要时用@MockitoSettings(strictness = Strictness.LENIENT)或lenient()显式放宽。
17.1.8 测 Service 层:该 mock 什么、不该 mock 什么
判断标准只有一条:mock 跨越边界的协作者,不 mock 被测逻辑本身的数据。
| 该 mock | 理由 |
|---|---|
| Repository / DAO | 真连数据库就变成集成测试了 |
| 外部 HTTP 客户端、消息发送器 | 依赖网络,不可控 |
| 时钟、随机数、UUID 生成器 | 需要确定性输出 |
| 不该 mock | 理由 |
|---|---|
| 被测对象自身 | 那就没在测任何东西 |
领域实体 / 值对象(Book、Money) | 用真实对象构造更简单,也更能暴露问题 |
集合、Optional、String | 用真实值即可 |
| 仅仅为「让测试看起来干净」而 mock 的一切 | 过度 mock 会把测试变成对实现的复述 |
一个判断过度 mock 的信号:测试里 when(...) 的行数比 assert 还多。这时测试校验的其实是「你写了哪些调用」,而不是业务行为是否正确;一旦重构内部实现,这种测试会成片失败,却抓不到任何真实缺陷。
17.1.9 完整示例:BookService 单元测试
本节及后续两节复用同一个图书服务(领域模型 Book 与 BookRepository 见第 12 章):
package com.example.library.service;
import java.util.List;
import org.springframework.stereotype.Service;
import com.example.library.domain.Book;
import com.example.library.repository.BookRepository;
@Service
public class BookService {
private final BookRepository bookRepository;
public BookService(BookRepository bookRepository) {
this.bookRepository = bookRepository;
}
public Book create(Book book) {
bookRepository.findByTitle(book.getTitle()).ifPresent(existing -> {
throw new DuplicateBookException(book.getTitle());
});
return bookRepository.save(book);
}
public Book getById(Long id) {
return bookRepository.findById(id)
.orElseThrow(() -> new BookNotFoundException(id));
}
}
对应的单元测试。注意全程没有 @SpringBootTest,没有容器:
package com.example.library.service;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Optional;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import com.example.library.domain.Book;
import com.example.library.repository.BookRepository;
@ExtendWith(MockitoExtension.class)
class BookServiceTest {
@Mock
private BookRepository bookRepository;
@InjectMocks
private BookService bookService;
@Nested
@DisplayName("create")
class Create {
@Test
@DisplayName("标题不重复时保存并返回图书")
void create_withNewTitle_savesBook() {
Book input = new Book("Effective Java", "Joshua Bloch", 2018);
when(bookRepository.findByTitle("Effective Java")).thenReturn(Optional.empty());
when(bookRepository.save(any(Book.class))).thenAnswer(inv -> inv.getArgument(0));
Book result = bookService.create(input);
assertAll("saved book",
() -> assertEquals("Effective Java", result.getTitle()),
() -> assertEquals(2018, result.getPublishedYear()));
ArgumentCaptor<Book> captor = ArgumentCaptor.forClass(Book.class);
verify(bookRepository).save(captor.capture());
assertEquals("Effective Java", captor.getValue().getTitle());
}
@Test
@DisplayName("标题已存在时抛出异常且不写库")
void create_duplicateTitle_throws() {
Book input = new Book("Effective Java", "Joshua Bloch", 2018);
when(bookRepository.findByTitle("Effective Java"))
.thenReturn(Optional.of(new Book("Effective Java", "Someone Else", 2015)));
assertThrows(DuplicateBookException.class, () -> bookService.create(input));
verify(bookRepository, never()).save(any(Book.class));
}
}
@Nested
@DisplayName("getById")
class GetById {
@Test
@DisplayName("存在时返回图书")
void getById_found_returnsBook() {
when(bookRepository.findById(1L))
.thenReturn(Optional.of(new Book("Effective Java", "Joshua Bloch", 2018)));
assertEquals("Effective Java", bookService.getById(1L).getTitle());
}
@Test
@DisplayName("不存在时抛出 BookNotFoundException")
void getById_missing_throws() {
when(bookRepository.findById(999L)).thenReturn(Optional.empty());
BookNotFoundException ex = assertThrows(
BookNotFoundException.class,
() -> bookService.getById(999L));
assertEquals(999L, ex.getId());
}
}
}
@Nested 把两组用例分门别类,运行报告会显示成树形结构,比一长串平铺方法好读得多。
17.1.10 常见坑
| 现象 | 原因与对策 |
|---|---|
被测方法里 NullPointerException | 某个 @Mock 没被注入或桩没打;先确认 @ExtendWith(MockitoExtension.class) 在,再查 @InjectMocks |
桩返回 null 导致空指针 | 未打桩的方法默认返回 null,Optional 场景要显式 thenReturn(Optional.empty()) |
UnnecessaryStubbingException | 打了没用到的桩;删掉它,或确认测试是否走错了分支 |
| 想测私有方法 | 私有方法是实现细节,应通过公有方法间接覆盖;硬测私有方法说明该类职责该拆了 |
| 测试之间互相影响 | 每个方法用独立实例;不要在字段里共享可变状态,@BeforeEach 里重建 |
小结
- 测试金字塔决定了「多写单元测试」:单元测试快、稳、定位准,是性价比最高的层级。
- 单元测试只测一个类,依赖用替身替换,不启动 Spring 容器。
spring-boot-starter-test聚合了 JUnit 5、Mockito、AssertJ、JsonPath、Awaitility 等库;单元测试的主角是 JUnit 5 + Mockito。- JUnit 5 常用注解:
@Test/@BeforeEach/@AfterEach/@DisplayName/@Nested/@ParameterizedTest。 - 断言优先用
assertEquals/assertThrows/assertAll;同一对象的多个字段校验用assertAll一次跑完。 - Mockito 三件套:
@Mock造替身、when(...).thenReturn(...)打桩、verify(...)校验;ArgumentCaptor用于断言传入参数。 - 4.x 下用
@ExtendWith(MockitoExtension.class)集成 Mockito,旧的MockitoTestExecutionListener机制已移除;默认严格打桩。 - mock 跨越边界的协作者(Repository、外部客户端、时钟),不 mock 被测逻辑本身的数据(实体、集合)。
- 用 given-when-then 分段、
方法名_条件_期望结果命名,让测试自己会说话。
Service 层的纯逻辑已经有保障。但 Controller 的请求映射、参数绑定、JSON 序列化,以及 Repository 的查询方法,都还没被验证。下一节我们用切片测试,只启动需要的那一层。
阅读导航:上一节:16.3 日志实践 · 下一节:17.2 切片测试 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。