单元测试入门:JUnit 5 + Mockito 打桩与断言
「我写的代码能跑」和「我写的代码在别人乱改之后还能跑」是两回事。测试就是一份会自己执行的说明书:它把「给定这些输入,应该得到这个结果」写成代码,每次有人改动就跑一遍,几十秒内告诉你哪条约定被破坏了。而单元测试是其中最便宜、最快的一档——它只测一个类里的逻辑,并且把它依赖的数据库、外部接口全部换成假的替身,所以毫秒级就能给出结论。这一篇讲三件事:怎么组织一个测试类(生命周期与命名)、怎么用假对象喂出想要的场景(打桩与验证)、以及什么时候不该用假对象(Mock 的边界)。
先把五个词一句话解释清楚:
- 单元测试:只测一个类的逻辑,把所有外部依赖换成可控替身,运行在毫秒级
- Mock(模拟对象):一个「按剧本回答问题的假对象」。你说「被问到 id=1 时返回 alice」,它就照做,还会记住自己被问过几次
- 断言:一句「我认为结果应该是 X」的硬检查。不成立就让这个测试变红并打印实际值
- 测试替身:所有「代替真角色的假对象」的统称,包含 Dummy / Stub / Spy / Mock / Fake 五种分工
- 上下文:Spring 那台装着所有 Bean 的大机器。启动它要几秒,所以纯单元测试刻意不碰它
Mock 就是动作片里的替身演员。主角(你的业务逻辑)该演的戏还是他自己演,但那些高风险、贵、慢或不该真做的动作——从二楼跳下、开真法拉利、和真银行对接——交给替身。导演喊「这里你要被打倒三次」,替身就倒三次;片子拍完还能核对「替身到底被用了几个镜头」(这就是 verify)。好处很明显:不用封路、不用买保险、拍摄一天能跑二十条;坏处也很致命——如果替身的走位和真人的习惯不一样,成片就会「排演全过、实拍翻车」,这正对应「单元测试全绿、上线炸在 SQL 上」。

这张时间轴是第三节的骨架:两头各执行一次(@BeforeAll / @AfterAll),中间每个用例都重新走一轮 Before → Test → After。看懂它,你就知道为什么「用例之间不能互相依赖」——因为每个用例开始时,世界都被重造过一次。
学完这一篇,你应该能回答三个问题:
- 我要验证「金额满 100 免运费」这条规则,需要启动 Spring 吗?为什么?
- 想让某个依赖「抛异常」来测失败分支,该怎么写?写完怎么确认它真的被调用了一次?
- 测试红了,控制台那句
Wanted but not invoked ... zero interactions到底在说什么?
先承认一个现实:很多人讨厌写测试,是因为他们把测试当成了「额外工作量」,而不是「设计工具」。一个方法如果很难写单元测试,通常说明它耦合太重、职责不清——测试的第一个价值,其实是逼你把代码写得更容易被拼装。
「单元测试」到底测什么?它只测一个类或一个方法,把外部依赖全部替换成可控的替身。这就划出了三类测试的边界:
| 层级 | 覆盖范围 | 运行速度 | 维护成本 | 典型工具 |
|---|---|---|---|---|
| 单元测试 | 单个类 / 方法,隔离所有外部依赖 | 毫秒级 | 低 | JUnit 5 + Mockito |
| 集成测试 | 多个组件 + 容器 / 数据库 | 秒级到数十秒 | 中 | @SpringBootTest + Testcontainers |
| 端到端测试 | 整条用户链路 | 数十秒到数分钟 | 高 | Playwright / RestAssured |

Spring Boot 项目里,单元测试最大的纪律是离开容器。启动一次 Spring 上下文动辄几秒,若每个测试都依赖它,跑完几百个用例要几分钟——反馈一慢,人就懒得跑,测试就死了。
金字塔是静止的,但「这一层凭什么归它管」得一条条问。下面这张图把四层叠起来,点一层看一层的分工——尤其第 ② 层(切片),它是大多数团队最容易整个跳过、又最可惜的一层:
单元测试该覆盖的是业务分支:if 的每一条路径、边界值、异常路径。它不该去验证「Spring 能不能把 Bean 装配起来」(那是集成测试的活),也不该去测框架本身。
那这两档到底差在哪?下面这段动画让同一个用例先走单元路、再走集成路,你可以逐帧对比两边的耗时与被证明的东西——注意第四帧那个「单元路的盲区」,它是本节最后一句话的图像版:

单元路是在摄影棚里拍:绿幕、替身、封路申请都不用打,一条镜头三十秒重拍一次,所以你能负担得起把每条分支都拍十遍。集成路是去真实街区实拍:车是真的、银行柜台是真的、太阳下山前必须拍完,所以一天只能拍三条——你只会用它拍最重要的那几个镜头(下单主流程、支付回调)。谁都不是「正确姿势」,问题是这条约定值得哪一种成本。
不用逐个引依赖,Spring Boot 用一个 starter 把测试全家桶打包好了:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope></dependency>它一次性带进来这些常用库:
| 库 | 作用 | 你会常用到的 |
|---|---|---|
| JUnit 5(Jupiter) | 测试引擎与注解 | @Test / @BeforeEach / @ParameterizedTest |
| Mockito | 打桩与验证 | @Mock / when() / verify() |
| AssertJ | 流式断言 | assertThat(...).isEqualTo(...) |
| Hamcrest | 匹配器(老项目常见) | assertThat(x, is(y)) |
| JSONassert / JsonPath | JSON 断言(Web 测试用) | jsonPath("$.id") |
| Spring Test | 集成测试支持 | @SpringBootTest / MockMvc |
一个 starter 就打包好了全家桶,但每个项目还要再补两三样:测持久层要不要 H2、要不要 Testcontainers 起真库、Lombok 是否只作用于测试代码。勾一遍,重点看每一行的 <scope>——第十四节第三档那个「整个测试类跑完 < 1 秒」的前提,就在这几行里:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version> <!-- 版本由 BOM 统管,子依赖不写 version -->
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>demo-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>17</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>目录约定:源码在 src/main/java,测试在 src/test/java,包名与类结构一一对应,类名通常是 被测类名 + Test:
src/test/java/com/example/demo/service/UserServiceTest.java提示:Maven 只把 src/test/java 下的类当测试,且测试依赖是 test 作用域——不会被打进生产 jar。别把测试类放进 src/main,那会被打进制品。
JUnit 5 用注解管理一个测试类的「生命周期」。一套标准骨架长这样:
class OrderServiceTest { private OrderService orderService; private OrderRepository repository; @BeforeAll // 整个类执行一次:适合建昂贵资源 static void initAll() { System.out.println("== 类级初始化 =="); } @BeforeEach // 每个测试方法前执行:把被测对象恢复成干净状态 void setUp() { repository = mock(OrderRepository.class); orderService = new OrderService(repository); } @AfterEach // 每个测试方法后执行:清理临时状态 void tearDown() { // 单元测试里通常无需做什么;此处演示结构 } @Test void should_return_order_when_id_exists() { // Given(准备) Order order = new Order(1L, new BigDecimal("99.00")); when(repository.findById(1L)).thenReturn(Optional.of(order)); // When(执行) Order result = orderService.getById(1L); // Then(断言) assertThat(result.getAmount()).isEqualByComparingTo("99.00"); }}三个生命周期注解的分工:@BeforeEach 是「每个用例前把场地擦干净」,@BeforeAll 是「整个类只做一次的昂贵准备」(它必须是 static),@AfterEach 负责收尾。
Given-When-Then 三段式是测试可读性的关键:准备数据 → 执行动作 → 断言结果,三段之间不要互相穿插。命名规范则推荐 should_xxx_when_yyy,让测试失败时光看方法名就知道哪里坏了:
| 命名 | 评价 |
|---|---|
test1 | 毫无信息量,失败后要读代码 |
testGetById | 只说了「测什么」,没说「验证什么」 |
should_return_order_when_id_exists | 行为 + 条件,一句话读懂 |
测试之间不能相互依赖。 JUnit 默认不保证方法执行顺序,若 B 用例依赖 A 用例的副作用,单跑 B 就会莫名其妙失败。每个用例都必须是「自给自足、独立可跑」的。
第十节会把这套顺序做成可交互的实验(含 @BeforeAll 到断言失败的完整时间线),这里先记住一句话:两头各一次,中间每用例一轮。
顺序这件事光看图还是会记反,特别是「@BeforeEach 到底跑几次」。把它摊成一次单步执行:左边就是这个测试类的八行,右边同步刷新「跑到这一步时的实例与状态」。连点下一步,盯第 ⑥ 步——Mock 又是全新的、计数器归零,这一格就是「用例之间不许互相依赖」的物理解释:
@BeforeAll static void initAll() // ① 整个类只跑一次,必须是 static@BeforeEach void setUp() // ② 每个用例前:重造 Mock 与被测对象@Test void should_return_order() { // ③ 用例①开始 when(repository.findById(1L)).thenReturn(Optional.of(order)); // ④ 打桩 assertThat(result.getAmount()).isEqualByComparingTo("99.00"); // ⑤ 断言@AfterEach void tearDown() // ⑥ 用例①结束:清场@BeforeEach void setUp() (用例②) // ⑦ 用例②开始前:一切又是新的@AfterAll static void closeAll() // ⑧ 整个类最后一次收尾| 执行次数 | 类级 1 次 |
| 实例 | 还没有测试实例 |
| 为什么必须 static | 实例方法此刻无人可调 |
JUnit Jupiter EngineClassPredicateJUnit 5 自带断言够用,但 AssertJ 的流式写法更贴近自然语言,也更难写错:
// JUnit 5 原生断言assertEquals(5, list.size());assertThrows(IllegalArgumentException.class, () -> service.withdraw(-1));assertAll("订单校验", () -> assertEquals("A001", order.getNo()), () -> assertEquals(Status.CREATED, order.getStatus()));assertTimeout(Duration.ofMillis(200), () -> service.quickTask());// AssertJ 流式断言:链式组合,失败信息更直观List<User> users = userService.findActive();assertThat(users) .hasSize(3) .extracting(User::getUsername) .containsExactlyInAnyOrder("alice", "bob", "carol");assertThatThrownBy(() -> service.withdraw(-1)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("金额");assertThat(order.getAmount()).isEqualByComparingTo("99.00"); // BigDecimal 用它,别用 equals| 需求 | JUnit 5 | AssertJ |
|---|---|---|
| 相等 | assertEquals(a, b) | assertThat(a).isEqualTo(b) |
| 抛异常 | assertThrows(Ex.class, ...) | assertThatThrownBy(...).isInstanceOf(...) |
| 集合内容 | 需手写循环比较 | .extracting(...).containsExactly(...) |
| 多断言聚合 | assertAll(...) | 一条链多个断言 |
| BigDecimal | 要指定 delta | .isEqualByComparingTo() 更直观 |
BigDecimal 千万别用 equals 比较——new BigDecimal("1.0") 与 new BigDecimal("1.00") 的 equals 返回 false(scale 不同)。断言金额一律用 isEqualByComparingTo 或 compareTo。
Mockito 解决「外部依赖怎么办」:用一个假对象替换真依赖,规定它被问到时回答什么。看一个完整可运行的服务层测试类:
@ExtendWith(MockitoExtension.class) // 没有它,@Mock 不会生效(新手第一坑)class UserServiceTest { @Mock private UserRepository userRepository; // 假的仓库,不碰数据库 @InjectMocks private UserService userService; // 被测对象,Mockito 自动把上面的 @Mock 注入进去 @Test void should_return_user_when_found() { // Given:规定 findById(1) 返回一个用户 User user = new User(1L, "alice"); when(userRepository.findById(1L)).thenReturn(Optional.of(user)); // When User result = userService.getById(1L); // Then assertThat(result.getUsername()).isEqualTo("alice"); verify(userRepository, times(1)).findById(1L); // 验证确实只调了一次 } @Test void should_throw_when_not_found() { when(userRepository.findById(9L)).thenReturn(Optional.empty()); assertThatThrownBy(() -> userService.getById(9L)) .isInstanceOf(NoSuchElementException.class); } @Test void should_pass_saved_user_to_repository() { // ArgumentCaptor:把传给仓库的参数「抓」出来检查 ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class); userService.register("bob", "bob@example.com"); verify(userRepository).save(captor.capture()); assertThat(captor.getValue().getUsername()).isEqualTo("bob"); assertThat(captor.getValue().getEmail()).isEqualTo("bob@example.com"); }}
把常用武器整理成一张表:
| 写法 | 作用 |
|---|---|
when(x).thenReturn(v) | 规定「被调用时返回 v」 |
when(x).thenThrow(ex) | 规定「被调用时抛异常」,测异常分支 |
doNothing().when(mock).voidCall() | void 方法默认什么都不做,需要显式 stub 副作用时用 |
verify(mock, times(n)) | 验证被调用了 n 次 |
verify(mock, never()) | 验证从未被调用 |
ArgumentCaptor<T> | 捕获实参,做深度校验 |
@Mock 不生效,九成是忘了 @ExtendWith(MockitoExtension.class)。 没有这个扩展,Mockito 不会去扫描并注入注解,字段会是 null,调用时直接 NPE。另一种常见原因是用了 @MockBean(那是 Spring 的,属于集成测试范畴),两者别混。
Mock 用过头,测试会退化成「验证代码自己调用了自己」——它只是把你写的过程又描述了一遍,什么都没验证到。划两条线:
- 该 Mock:外部 I/O(数据库、HTTP、消息队列)、慢资源、不确定的依赖(当前时间、随机数、UUID)
- 不该 Mock:简单的值对象、纯函数工具类、你自己的领域实体——直接 new 出来用,比搭 Mock 更真实
// ❌ 过度 Mock:连值对象都 mock,测试变得脆弱且无意义Order mockOrder = mock(Order.class);when(mockOrder.getAmount()).thenReturn(new BigDecimal("10"));// ✅ 值对象直接 new,只有真正的边界(仓库)才需要 MockOrder order = new Order(1L, new BigDecimal("10"));when(repository.findById(1L)).thenReturn(Optional.of(order));说明:判断标准很朴素——「这个东西如果出问题,是不是我想在单元测试里定位的?」 是框架或用外部系统的毛病,就 Mock;是你自己业务逻辑的一部分,就让它真跑。
把这条判据画成左右两栏,划线的位置就一目了然了。左列每一项都「不该由你在单元测试里定位」,右列每一项一旦被 Mock,测试就退化成复述自己的代码:

同一段逻辑要验证多个输入时,别复制十个 @Test,用 @ParameterizedTest:
@ParameterizedTest@ValueSource(ints = {0, -1, -100})void should_reject_non_positive_amount(int amount) { assertThatThrownBy(() -> service.withdraw(amount)) .isInstanceOf(IllegalArgumentException.class);}@ParameterizedTest@CsvSource({ "1, alice, 10.0", "2, bob, 0.0", "3, carol, -5.5"})void should_parse_order_line(long id, String name, BigDecimal amount) { Order order = parser.parse(id + "," + name + "," + amount); assertThat(order.getId()).isEqualTo(id);}@ParameterizedTest@MethodSource("provideBoundaryCases")void should_classify_boundary(Order order, Level expected) { assertThat(classifier.classify(order)).isEqualTo(expected);}static Stream<Arguments> provideBoundaryCases() { return Stream.of( Arguments.of(new Order(1L, new BigDecimal("0")), Level.LOW), Arguments.of(new Order(2L, new BigDecimal("100")), Level.MID), Arguments.of(new Order(3L, new BigDecimal("9999")), Level.HIGH) );}| 数据源注解 | 适合 |
|---|---|
@ValueSource | 单一类型的简单值(int / String) |
@CsvSource | 多参数、可直接写成一行 |
@MethodSource | 复杂对象、需要构造逻辑的用例 |
@EnumSource | 遍历某个枚举的所有值 |
参数化测试的用例名默认带下标(如 [2] -5),出问题时不易定位。加 @ParameterizedTest(name = "第 {index} 组: {0} 应被拒绝") 可以让报告直接说人话。
「测试替身」是一个统称,五种替身职责不同,面试常问:
| 替身 | 一句话 | 行为 |
|---|---|---|
| Dummy | 占位用,从不真正被使用 | 只为填满参数列表 |
| Stub | 被调用时返回预设值 | 只管「回答什么」 |
| Spy | 包装真实对象,可选择性地打桩 | 默认走真实逻辑,可局部替换 |
| Mock | 记录调用,供事后验证 | 关心「有没有被按约定调用」 |
| Fake | 有真实行为的简化实现 | 如内存版仓库 |
@Spy 的实际用法——想要真实逻辑,只替换其中一个方法:
@Spyprivate AuditService auditService = new AuditService(); // 默认调用真实方法@Testvoid should_skip_real_write_but_count_calls() { doNothing().when(auditService).flush(); // 只把 flush 打个桩 auditService.record("login"); // 真实逻辑执行 verify(auditService).flush(); // 但 flush 被替换了}注意:@Spy 很诱人,但它容易掩盖设计问题——如果一段代码需要 Spy 才能测,往往说明这段逻辑该被拆出去单独测。Spy 应作为例外,而非常态。
Maven 里加一个插件就能生成覆盖率报告:
<plugin> <groupId>org.jacoco</groupId> <artifactId>jacoco-maven-plugin</artifactId> <version>0.8.11</version> <executions> <execution> <id>prepare-agent</id> <goals><goal>prepare-agent</goal></goals> </execution> <execution> <id>report</id> <phase>verify</phase> <goals><goal>report</goal></goals> </execution> </executions></plugin>执行 mvn verify 后,报告落在 target/site/jacoco/index.html。
覆盖率是「体检指标」,不是「绩效指标」。 为了凑数字去写「调一下方法、不做任何断言」的测试,覆盖率能到 100% 却毫无价值——它甚至连「方法被调用过」这个最低要求都只能勉强证明。真正该关注的是分支覆盖(if 的两条路都走了吗)和你最重要的业务逻辑是否被测到,而不是那个百分比。
不过「不设门槛」和「门槛 100%」这两种失败是同一个词,代价却完全相反。把这条线拖一遍,看它两端各自逼出什么样的测试代码:
- 60~70% 意味着主干分支基本被覆盖,剩下的多是 getter 与配置类
- 想再往上,收益开始低于成本:你是在测框架还是在测业务?
- 必须同时盯**分支覆盖**,行覆盖 70% 可能一条 if 都没进过 else
- 建议与「每条用例至少一个断言」的评审规则配套
前面写的全是纸面规则。下面六个实验分别回答:一个测试类到底按什么顺序跑、假对象是怎么被塞进去的、什么时候必须让真容器上场、断言失败的那句红字怎么读、切片测试到底拦住了哪一段,以及为什么「Failed to load ApplicationContext」不该出现在单元测试里。
进实验室之前,先把注解和它的职责配一遍——测试这块的注解不像 @Service 那样「写了就生效」,每一个都各管一件不同的事,配错一次就少踩一个坑:
第一个是主角,四档对应 Mockito 的四种典型动作:run 看执行顺序(和第三节的骨架一一对照)、mock 看依赖被替换的瞬间、verify 看交互次数的记账、fail 直接给你一条真实的失败信息:
第二个解决新手最普遍的疑惑:@Mock 写在字段上,凭什么就有值了?这背后是一次装配,和你在第 6 篇学的注入是同一套时机模型:
第三个用来确认第一节那条纪律:一旦你想验证的是「Spring 能不能装起来」「注解有没有真的生效」,就已经离开单元测试的地盘了。选 @SpringBootTest 启动 感受它多做了哪些事,再切「上下文缓存复用」看为什么第二次跑会快很多:
第四个回到起点:被测对象交给容器管之后,它的构造、初始化、销毁都不再由你的测试决定——这正是单元测试要绕开容器的原因,也是本节最后一个实验:
第五个补上切片测试那块空白:@WebMvcTest 到底拦住了哪一段。切 /users/42 看路径变量怎么绑成参数,再切 /nope 看 404 是从哪一层返回的——这些正是单元测试永远证明不了、而起全容器又太贵的东西:
第六个对着第十三节最后那条报错:Failed to load ApplicationContext。它出现在单元测试里,本质是「这个类不该起容器」;先切「启动失败怎么读」看清那一大坨的读法,再回来看你的测试类是不是混进了 @SpringBootTest:
第三个实验里那句「第二次跑会快很多」,靠的是上下文缓存。这张动图把它的六个节点摊开——注意第 ⑤ 帧:多加一个 @MockitoBean 就会让 key 改变、于是再起一个容器。团队里「测试越加越慢」九成不是代码变多,而是这一格失控:

复用率是一个真数值旋钮,而且它的成本藏在 CI 的墙上时钟里。拖一遍就明白为什么要「让集成测试类的配置尽量一致」:
- 4 条线程大约对应一台 CI 机器的一半核数
- 需要同时开 @Execution(CONCURRENT) 或全局 mode=concurrent
- 纯单元测试并行几乎无副作用,因为它们互不共享状态
- 这一档能把几分钟的测试阶段压到一分钟内
实验做到这里,可以换成命令行自己敲了。下面这台控制台连着浏览器里的同一个 Java 内核,回显全部由内核算出来——先 beans 看容器里装了什么,再把「打桩 → 验证 → 失败」这条链敲一遍:
lab mock verify 和 lab mock fail 要连着敲——前者把「Mock 到底在数什么」打印出来,后者给一句真实的红字。对照第十三节那张表读,你会发现所有失败信息都只写着两件事:期望是什么、实际发生了几次。
「该不该起容器」这个问题答错,代价有两种相反的方向:单元化不足→跑一次测试十分钟,没人愿意跑;过度集成→测试全绿但业务分支一个没测,改了照样炸。这个沙盘把「你想验证的约定是什么」做成开关,切一档就同屏看到选型、耗时和被证明的东西:
选型:纯 JUnit 5 + Mockito(不写 @SpringBootTest)耗时:单个用例 2~5ms,全模块 1200 个用例 ≈ 8sRepository / HTTP 客户端全部 @Mock# 判据:结论只取决于你自己写的 if 与计算 → 单元盲区:SQL 拼错、事务没生效,这里永远测不出来
四个选项的排序就是成本排序。先问「这条约定错了会怎样」,再问「最小能证明它的工具是谁」——而不是反过来先决定要不要起容器。第十二节第一道题会考的就是这条推理链。
先来一道热身题,考的是第十节 fail 那一档你会亲眼看到的报错:
再来一道综合题,把第二、五、六节串起来:
测试变红不可怕,可怕的是读不懂那几行红字。下面这些片段都能原样复制去搜索,右侧给出「人话翻译」。
| 报错原文(片段) | 真实原因 | 30 秒自救 | 深挖看第几篇 |
|---|---|---|---|
org.mockito.exceptions.misusing.UnnecessaryStubbingException: Unnecessary stubbings detected. Clean & maintainable test code requires zero unnecessary code. | 严格模式下,某个 when(...) 打的桩从头到尾没被用到——通常是你后来改了业务代码,或者这段桩属于另一个用例 | 删掉那条多余的 when;确实需要宽松模式时在类上加 @MockitoSettings(strictness = Strictness.LENIENT),但要先想清楚为什么用不上 | 本篇第五节 |
Wanted but not invoked ... However, there were exactly 0 interactions with this mock | 你以为会被调用的方法压根没被调用:分支没走到、参数不匹配,或依赖没被注入成 Mock | 先看是不是 null(NPE 会更早出现);再检查参数匹配器是否与真实入参一致;最后确认被测方法真的调用了它 | 本篇第五节 |
Wanted but not invoked ... there were exactly 2 interactions with this mock | 期望次数与实际不符(业务重复调用,或你把 times(1) 写错了) | 若确实是两次合法调用,改 times(2);若不是,这就是一个真 bug,回去查循环 | 本篇第十二节 |
Argument(s) are different! Wanted: repo.save(<Order@1a2b>) Actual invocations have different arguments: repo.save(<Order@3c4d>) | 默认按 equals() 比对参数,而你的实体没重写 equals,于是同一份数据也被判为不同 | 给值对象补 equals/hashCode(或直接改用 record);也可用 argThat(...)/ArgumentCaptor 逐字段断言 | 本篇第五节 |
java.lang.NullPointerException: Cannot invoke "OrderRepository.findById(...)" because "this.repository" is null | @Mock / @InjectMocks 完全没生效——九成是忘了 @ExtendWith(MockitoExtension.class) | 加上扩展注解;或手工 MockitoAnnotations.openMocks(this) 放在 @BeforeEach | 本篇第五节 |
Missing parameter values for the following placeholders: {0}(参数化测试) | @ParameterizedTest(name = "...") 里的 {0} 引用的参数不存在,或 @CsvSource 列数与方法签名不符 | 核对占位符下标与参数顺序;{index} 是用例序号、{0} 是第一个参数 | 本篇第七节 |
No tests found for given includes / Maven 跑了 0 个测试 | 类名不符合 Surefire 约定(不以 Test/Tests/TestCase 结尾),或测试类被放进了 src/main/java | 改名或移动目录;IDEA 里单独跑一次确认能被识别 | 第 34 篇 |
Failed to load ApplicationContext | 表面像单元测试挂了,实际上是被测类里混进了 @SpringBootTest,装配失败连坐一片 | 纯单元测试不该出现该异常:把容器相关用例挪到集成测试包,用不同 profile 分开跑 | 第 34 篇 |
静态方法 / 构造器 Mock:MockedStatic 一整套样板,或用 PowerMock 才能测 | 这不是框架坑,而是设计信号——你依赖了一个无法替换的静态协作者 | 首选重构:把时间/随机数/ID 生成作为参数或注入的 Clock、IdGenerator 传进来;真要 Mock 静态就用 Mockito 的 mockStatic 并确保 try-with-resources 关闭 | 本篇第六节 |
读红字时只看两段就够了——冒号前的异常类名告诉你「谁在抱怨」(misusing 属于打桩姿势问题,comparison 属于断言问题,wanted 属于验证问题),冒号后的第一句告诉你「差在哪」。别从第三行开始逐字读堆栈。
表里倒数第二条 Failed to load ApplicationContext 值得单独练一次:它有一屏长的输出、连着拖垮一片用例,而真正的原因常常只是「这个类压根不该起容器」。先别看答案,点出你认为的凶手行:
给 OrderService 加了一条测试用例,本地跑别的类都正常,只有这个类红了——而且同一批有 14 条用例一起红。
建一个 src/test/java/com/example/demo/service/FreightRuleTest.java,把「满 100 免运费」这条规则测到能防住误改。完整可跑代码 + 预期输出:
package com.example.demo.service;import java.math.BigDecimal;public class Order { private final long id; private final BigDecimal amount; public Order(long id, String amount) { this.id = id; this.amount = new BigDecimal(amount); } public long getId() { return id; } public BigDecimal getAmount() { return amount; }}package com.example.demo.service;public interface ShippingClient { BigDecimal quote(long orderId); // 真实场景要打外部物流系统,所以必须 Mock}package com.example.demo.service;import java.math.BigDecimal;public class OrderService { private static final BigDecimal FREE_THRESHOLD = new BigDecimal("100"); private final ShippingClient shippingClient; public OrderService(ShippingClient shippingClient) { this.shippingClient = shippingClient; } /** 满 100 免运费:直接返回 0,并且根本不去询价 */ public BigDecimal freight(Order order) { if (order.getAmount().compareTo(FREE_THRESHOLD) >= 0) { return BigDecimal.ZERO; } return shippingClient.quote(order.getId()); }}package com.example.demo.service;import org.junit.jupiter.api.BeforeEach;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.extension.ExtendWith;import org.junit.jupiter.params.ParameterizedTest;import org.junit.jupiter.params.provider.CsvSource;import org.mockito.InjectMocks;import org.mockito.Mock;import org.mockito.junit.jupiter.MockitoExtension;import java.math.BigDecimal;import static org.assertj.core.api.Assertions.assertThat;import static org.mockito.ArgumentMatchers.anyLong;import static org.mockito.Mockito.never;import static org.mockito.Mockito.verify;import static org.mockito.Mockito.when;@ExtendWith(MockitoExtension.class)class FreightRuleTest { @Mock private ShippingClient shippingClient; // 外部物流系统:假扮即可 @InjectMocks private OrderService orderService; // 被测对象,Mockito 把上面的 @Mock 注进来 @ParameterizedTest(name = "金额 {0} 应付运费 {1}") @CsvSource({"99.99, 8.00", "100.00, 0", "100.01, 0"}) void should_charge_freight_below_threshold_only(String amount, String expected) { when(shippingClient.quote(1L)).thenReturn(new BigDecimal("8.00")); BigDecimal freight = orderService.freight(new Order(1L, amount)); assertThat(freight).isEqualByComparingTo(expected); } @Test void should_not_call_shipping_client_when_amount_reaches_threshold() { BigDecimal freight = orderService.freight(new Order(7L, "100")); assertThat(freight).isEqualByComparingTo("0"); verify(shippingClient, never()).quote(anyLong()); // 关键:证明「根本没去询价」 }}预期输出(mvn test 或 IDEA 里直接跑这个类):
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0FreightRuleTest ✔ 金额 99.99 应付运费 8.00 ✔ 金额 100.00 应付运费 0 ✔ 金额 100.01 应付运费 0 ✔ should_not_call_shipping_client_when_amount_reaches_threshold两个观察点:① 三条参数化用例共用一段代码,边界值一眼可见;② 第二条用例里的 verify(never()) 才是这条规则的真正防线——如果哪天有人改成「先询价再判断满减」,功能结果不变但外部调用翻倍,这个测试立刻红。
目标:亲手制造并读懂第十三节那条最好笑的报错。
提示:在 should_not_call_shipping_client_when_amount_reaches_threshold 之前多加一行永远不会被用到的打桩:when(shippingClient.quote(99L)).thenReturn(new BigDecimal("12.00"));,然后重跑整个类。
你会观察到:两个用例都变成红色,报错是 UnnecessaryStubbingException,并且消息里直接点名是哪一行、哪个方法。这时候有两条路——把多余打桩删掉(正解),或者在类上加 @MockitoSettings(strictness = Strictness.LENIENT)(临时止痛)。请两种都试一次,然后自己解释为什么 Mockito 默认要这么「麻烦」:因为无用的打桩会让下一个读测试的人以为「这个依赖在这里是有意义的」,久而久之没人敢删代码。
给一个真实的小服务补齐测试:CouponService.applyDiscount(Order order, Coupon coupon),规则是「满减券按门槛减免、百分比券封顶 50 元、过期券直接拒绝」。
验收清单:
- [ ] 至少 8 个用例,其中一半是参数化的,覆盖「刚好等于门槛」「门槛减一分」「券已过期」「百分比券算出超过 50」四类边界
- [ ]
Coupon用 record 或手写equals/hashCode,因此可以直接verify(couponRepository).findById(3L)而不报Argument(s) are different - [ ] 时间通过注入的
Clock获得,测试里用Clock.fixed(...)冻结,全程不使用LocalDateTime.now() - [ ] 没有一个用例带
@SpringBootTest,整个测试类跑完 < 1 秒 - [ ] 故意把「封顶 50」改成「封顶 60」,至少有 2 个用例变红(证明它们真的在管事)
- [ ] 你能对每个用例说出它是 Given-When-Then 的哪一段,且三段之间没有互相穿插
@Mock 注入失败,多半是漏了 @ExtendWith(MockitoExtension.class);若用 @MockBean,那属于集成测试,会启动 Spring 上下文,别在纯单元测试里用。
测试相互依赖。JUnit 不保证执行顺序,任何「A 跑过 B 才能过」的测试都是定时炸弹;用 @BeforeEach 让每个用例拿到干净状态。
断言异常消息过度耦合。hasMessage("用户[1]不存在") 这种把文案写死的断言,改一次提示语就红一片。用 hasMessageContaining("不存在") 这类宽松匹配,让测试盯「行为」而不是「文案」。
不看资料,画出第三节的执行顺序:@BeforeAll → (@BeforeEach → @Test → @AfterEach)× N → @AfterAll。并且说出为什么 @BeforeAll 必须是 static。
when(...) 和 verify(...) 分别在回答什么问题?答案应是「返回什么」与「有没有被这样调用」,两者不可互换。
UnnecessaryStubbingException、Wanted but not invoked ... 0 interactions、Argument(s) are different 这三句各指向什么病根?说不出第三条就回去看 equals 那段。
哪些东西该 Mock、哪些不该?标准答案只有一句话——出问题时会由我在单元测试里定位的吗?
@Mock 和 @MockitoBean 的区别是什么?前者属于纯单元测试(毫秒级、不起容器),后者属于 Spring 集成测试(会启动上下文)。
JUnit 管顺序,Mockito 管替身;打桩答「返回什么」,verify 查「有没有干」;值对象直接 new,边界值参数化;红了先看类名再读首句,全绿也别忘了一句 verify(never())。
把这篇压成几句话——单元测试的价值在于逼你写出低耦合、可拼装的代码,并给你毫秒级的反馈;JUnit 5 管生命周期(@BeforeEach/@BeforeAll)与断言,Mockito 管打桩(when)与验证(verify/ArgumentCaptor);Given-When-Then 三段式 + should_xxx_when_yyy 命名让测试自解释;只在真正的边界上 Mock,值对象与纯函数直接 new;参数化测试消灭重复用例,@Spy 是例外而非常态;覆盖率是体检指标,别把它当绩效目标。掌握这些,你的测试才会成为资产,而不是负担。