单元测试入门:JUnit 5 + Mockito 打桩与断言

bee2026-10-0869 分钟0 次阅读
从"为什么测试写得像负担"讲到 JUnit 5 断言体系、@Mock/@InjectMocks 打桩、参数化测试与测试命名规范,配一套可运行的服务层测试。
1 / 145
小节
〇、30 秒看懂
2 / 145

「我写的代码能跑」和「我写的代码在别人乱改之后还能跑」是两回事。测试就是一份会自己执行的说明书:它把「给定这些输入,应该得到这个结果」写成代码,每次有人改动就跑一遍,几十秒内告诉你哪条约定被破坏了。而单元测试是其中最便宜、最快的一档——它只测一个类里的逻辑,并且把它依赖的数据库、外部接口全部换成假的替身,所以毫秒级就能给出结论。这一篇讲三件事:怎么组织一个测试类(生命周期与命名)、怎么用假对象喂出想要的场景(打桩与验证)、以及什么时候不该用假对象(Mock 的边界)。

3 / 145

先把五个词一句话解释清楚:

4 / 145
  • 单元测试:只测一个类的逻辑,把所有外部依赖换成可控替身,运行在毫秒级
  • Mock(模拟对象):一个「按剧本回答问题的假对象」。你说「被问到 id=1 时返回 alice」,它就照做,还会记住自己被问过几次
  • 断言:一句「我认为结果应该是 X」的硬检查。不成立就让这个测试变红并打印实际值
  • 测试替身:所有「代替真角色的假对象」的统称,包含 Dummy / Stub / Spy / Mock / Fake 五种分工
  • 上下文:Spring 那台装着所有 Bean 的大机器。启动它要几秒,所以纯单元测试刻意不碰它
5 / 145
类比

Mock 就是动作片里的替身演员。主角(你的业务逻辑)该演的戏还是他自己演,但那些高风险、贵、慢或不该真做的动作——从二楼跳下、开真法拉利、和真银行对接——交给替身。导演喊「这里你要被打倒三次」,替身就倒三次;片子拍完还能核对「替身到底被用了几个镜头」(这就是 verify)。好处很明显:不用封路、不用买保险、拍摄一天能跑二十条;坏处也很致命——如果替身的走位和真人的习惯不一样,成片就会「排演全过、实拍翻车」,这正对应「单元测试全绿、上线炸在 SQL 上」。

6 / 145
架构图
图 · 一个测试类的真实执行顺序
图 · 一个测试类的真实执行顺序
7 / 145

这张时间轴是第三节的骨架:两头各执行一次(@BeforeAll / @AfterAll),中间每个用例都重新走一轮 Before → Test → After。看懂它,你就知道为什么「用例之间不能互相依赖」——因为每个用例开始时,世界都被重造过一次。

8 / 145

学完这一篇,你应该能回答三个问题:

9 / 145
  • 我要验证「金额满 100 免运费」这条规则,需要启动 Spring 吗?为什么?
  • 想让某个依赖「抛异常」来测失败分支,该怎么写?写完怎么确认它真的被调用了一次?
  • 测试红了,控制台那句 Wanted but not invoked ... zero interactions 到底在说什么?
10 / 145
小节
一、单元测试的价值与边界
11 / 145

先承认一个现实:很多人讨厌写测试,是因为他们把测试当成了「额外工作量」,而不是「设计工具」。一个方法如果很难写单元测试,通常说明它耦合太重、职责不清——测试的第一个价值,其实是逼你把代码写得更容易被拼装。

12 / 145

「单元测试」到底测什么?它只测一个类或一个方法,把外部依赖全部替换成可控的替身。这就划出了三类测试的边界:

13 / 145
对照表
层级覆盖范围运行速度维护成本典型工具
单元测试单个类 / 方法,隔离所有外部依赖毫秒级低JUnit 5 + Mockito
集成测试多个组件 + 容器 / 数据库秒级到数十秒中@SpringBootTest + Testcontainers
端到端测试整条用户链路数十秒到数分钟高Playwright / RestAssured
14 / 145
架构图
图 1 · 测试金字塔与职责
图 1 · 测试金字塔与职责
15 / 145
说明

Spring Boot 项目里,单元测试最大的纪律是离开容器。启动一次 Spring 上下文动辄几秒,若每个测试都依赖它,跑完几百个用例要几分钟——反馈一慢,人就懒得跑,测试就死了。

16 / 145

金字塔是静止的,但「这一层凭什么归它管」得一条条问。下面这张图把四层叠起来,点一层看一层的分工——尤其第 ② 层(切片),它是大多数团队最容易整个跳过、又最可惜的一层:

17 / 145
交互图解
分层四层测试叠起来看:每一层只回答一个问题1 / 4
从 ① 点到 ④,重点在第 ② 层——它就是「既不想起容器、又想证明 Web 契约」的那一档
→
→
→
① 单元测试:这条规则算对了吗
被测范围是**一个类**,所有依赖换成 @Mock。毫秒级、可以写上千个、什么环境都不需要。它的盲区同样明确:SQL 拼错、事务没生效、Bean 没装上,这里一辈子测不出来——因为代理压根不存在。
全部看懂了从上到下:越往下越该多写、越快、越便宜;越往上越少、越慢、越贵——金字塔说的就是这个斜率。
18 / 145

单元测试该覆盖的是业务分支:if 的每一条路径、边界值、异常路径。它不该去验证「Spring 能不能把 Bean 装配起来」(那是集成测试的活),也不该去测框架本身。

19 / 145

那这两档到底差在哪?下面这段动画让同一个用例先走单元路、再走集成路,你可以逐帧对比两边的耗时与被证明的东西——注意第四帧那个「单元路的盲区」,它是本节最后一句话的图像版:

20 / 145
原理动画
动图 · 同一个用例走两条路:单元测试 vs 集成测试
动图 · 同一个用例走两条路:单元测试 vs 集成测试
21 / 145
类比

单元路是在摄影棚里拍:绿幕、替身、封路申请都不用打,一条镜头三十秒重拍一次,所以你能负担得起把每条分支都拍十遍。集成路是去真实街区实拍:车是真的、银行柜台是真的、太阳下山前必须拍完,所以一天只能拍三条——你只会用它拍最重要的那几个镜头(下单主流程、支付回调)。谁都不是「正确姿势」,问题是这条约定值得哪一种成本。

22 / 145
小节
二、JUnit 5 起步:spring-boot-starter-test 里到底有什么
23 / 145

不用逐个引依赖,Spring Boot 用一个 starter 把测试全家桶打包好了:

24 / 145
xml
<dependency>    <groupId>org.springframework.boot</groupId>    <artifactId>spring-boot-starter-test</artifactId>    <scope>test</scope></dependency>
25 / 145

它一次性带进来这些常用库:

26 / 145
对照表
库作用你会常用到的
JUnit 5(Jupiter)测试引擎与注解@Test / @BeforeEach / @ParameterizedTest
Mockito打桩与验证@Mock / when() / verify()
AssertJ流式断言assertThat(...).isEqualTo(...)
Hamcrest匹配器(老项目常见)assertThat(x, is(y))
JSONassert / JsonPathJSON 断言(Web 测试用)jsonPath("$.id")
Spring Test集成测试支持@SpringBootTest / MockMvc
27 / 145

一个 starter 就打包好了全家桶,但每个项目还要再补两三样:测持久层要不要 H2、要不要 Testcontainers 起真库、Lombok 是否只作用于测试代码。勾一遍,重点看每一行的 <scope>——第十四节第三档那个「整个测试类跑完 < 1 秒」的前提,就在这几行里:

28 / 145
生成器
生成器测试该勾哪几行依赖pom.xml4 / 9
先只勾 Test 拿到最小可用的 spring-boot-starter-test;再叠 JPA + H2 看内存库为什么该是 test scope;MySQL 那一行的 scope 与 H2 正好相反,对照第 34 篇的集成测试配置
产物
<?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>
勾了这些,代价与理由在这里
parent继承 3.3.4 的 starter-parent 之后,所有 spring-boot-starter-* 都不用写版本号;一旦有人手写给某个 starter 加 version,就以那条为准——这是依赖版本漂移最常见的原因。
Web做接口就绕不开它: DispatcherServlet、内嵌 Tomcat、JSON 序列化全在这个 starter 里。
Data JPA包含 spring-orm + Hibernate;写 Repository 接口就有实现,不用自己拼 SQL。
H2 内存库runtime scope,让本地启动和测试不用真库;生产 profile 记得排除。
Testscope=test;@SpringBootTest、MockMvc、AssertJ 都在里面,漏了就找不到 @Test。
29 / 145

目录约定:源码在 src/main/java,测试在 src/test/java,包名与类结构一一对应,类名通常是 被测类名 + Test:

30 / 145
代码对照
代码text
src/test/java/com/example/demo/service/UserServiceTest.java
解读

提示:Maven 只把 src/test/java 下的类当测试,且测试依赖是 test 作用域——不会被打进生产 jar。别把测试类放进 src/main,那会被打进制品。

31 / 145
小节
三、测试生命周期与结构
32 / 145

JUnit 5 用注解管理一个测试类的「生命周期」。一套标准骨架长这样:

33 / 145
java
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");    }}
34 / 145

三个生命周期注解的分工:@BeforeEach 是「每个用例前把场地擦干净」,@BeforeAll 是「整个类只做一次的昂贵准备」(它必须是 static),@AfterEach 负责收尾。

35 / 145

Given-When-Then 三段式是测试可读性的关键:准备数据 → 执行动作 → 断言结果,三段之间不要互相穿插。命名规范则推荐 should_xxx_when_yyy,让测试失败时光看方法名就知道哪里坏了:

36 / 145
对照表
命名评价
test1毫无信息量,失败后要读代码
testGetById只说了「测什么」,没说「验证什么」
should_return_order_when_id_exists行为 + 条件,一句话读懂
37 / 145
坑

测试之间不能相互依赖。 JUnit 默认不保证方法执行顺序,若 B 用例依赖 A 用例的副作用,单跑 B 就会莫名其妙失败。每个用例都必须是「自给自足、独立可跑」的。

38 / 145

第十节会把这套顺序做成可交互的实验(含 @BeforeAll 到断言失败的完整时间线),这里先记住一句话:两头各一次,中间每用例一轮。

39 / 145

顺序这件事光看图还是会记反,特别是「@BeforeEach 到底跑几次」。把它摊成一次单步执行:左边就是这个测试类的八行,右边同步刷新「跑到这一步时的实例与状态」。连点下一步,盯第 ⑥ 步——Mock 又是全新的、计数器归零,这一格就是「用例之间不许互相依赖」的物理解释:

40 / 145
单步调试台
单步台单步走完一个测试类:两头各一次,中间每用例一轮1 / 8
按 ①→⑧ 走,重点看第 ⑥ 步:第二个用例开始时,被测对象和 Mock 都是崭新的
被调试的代码
1@BeforeAll static void initAll() // ① 整个类只跑一次,必须是 static
2@BeforeEach void setUp() // ② 每个用例前:重造 Mock 与被测对象
3@Test void should_return_order() { // ③ 用例①开始
4 when(repository.findById(1L)).thenReturn(Optional.of(order)); // ④ 打桩
5 assertThat(result.getAmount()).isEqualByComparingTo("99.00"); // ⑤ 断言
6@AfterEach void tearDown() // ⑥ 用例①结束:清场
7@BeforeEach void setUp() (用例②) // ⑦ 用例②开始前:一切又是新的
8@AfterAll static void closeAll() // ⑧ 整个类最后一次收尾
此刻的变量
执行次数类级 1 次
实例还没有测试实例
为什么必须 static实例方法此刻无人可调
调用栈
1JUnit Jupiter Engine
2ClassPredicate
1@BeforeAll 跑在创建测试类实例之前——每条用例都会 new 一个新实例,所以「整个类一次」的钩子只能挂在类上,Java 语法要求它就是 static(除非你改成 @TestInstance(PER_CLASS))。昂贵资源(连接、临时目录)放这里,只此一次。
41 / 145
小节
四、断言体系:从 assertEquals 到 AssertJ
42 / 145

JUnit 5 自带断言够用,但 AssertJ 的流式写法更贴近自然语言,也更难写错:

43 / 145
java
// 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());
44 / 145
java
// 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
45 / 145
对照表
需求JUnit 5AssertJ
相等assertEquals(a, b)assertThat(a).isEqualTo(b)
抛异常assertThrows(Ex.class, ...)assertThatThrownBy(...).isInstanceOf(...)
集合内容需手写循环比较.extracting(...).containsExactly(...)
多断言聚合assertAll(...)一条链多个断言
BigDecimal要指定 delta.isEqualByComparingTo() 更直观
46 / 145
要点

BigDecimal 千万别用 equals 比较——new BigDecimal("1.0") 与 new BigDecimal("1.00") 的 equals 返回 false(scale 不同)。断言金额一律用 isEqualByComparingTo 或 compareTo。

47 / 145
小节
五、Mockito 核心武器:打桩与验证
48 / 145

Mockito 解决「外部依赖怎么办」:用一个假对象替换真依赖,规定它被问到时回答什么。看一个完整可运行的服务层测试类:

49 / 145
java
@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");    }}
50 / 145
原理动画
动图 · 一次打桩与验证的流程
动图 · 一次打桩与验证的流程
51 / 145

把常用武器整理成一张表:

52 / 145
对照表
写法作用
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>捕获实参,做深度校验
53 / 145
坑

@Mock 不生效,九成是忘了 @ExtendWith(MockitoExtension.class)。 没有这个扩展,Mockito 不会去扫描并注入注解,字段会是 null,调用时直接 NPE。另一种常见原因是用了 @MockBean(那是 Spring 的,属于集成测试范畴),两者别混。

54 / 145
小节
六、打桩边界:什么时候该 Mock,什么时候不该
55 / 145

Mock 用过头,测试会退化成「验证代码自己调用了自己」——它只是把你写的过程又描述了一遍,什么都没验证到。划两条线:

56 / 145
  • 该 Mock:外部 I/O(数据库、HTTP、消息队列)、慢资源、不确定的依赖(当前时间、随机数、UUID)
  • 不该 Mock:简单的值对象、纯函数工具类、你自己的领域实体——直接 new 出来用,比搭 Mock 更真实
57 / 145
代码对照
代码java
// ❌ 过度 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;是你自己业务逻辑的一部分,就让它真跑。

58 / 145

把这条判据画成左右两栏,划线的位置就一目了然了。左列每一项都「不该由你在单元测试里定位」,右列每一项一旦被 Mock,测试就退化成复述自己的代码:

59 / 145
架构图
图 · 该 Mock 什么、不该 Mock 什么
图 · 该 Mock 什么、不该 Mock 什么
60 / 145
小节
七、参数化测试:一份代码跑十组数据
61 / 145

同一段逻辑要验证多个输入时,别复制十个 @Test,用 @ParameterizedTest:

62 / 145
java
@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)    );}
63 / 145
对照表
数据源注解适合
@ValueSource单一类型的简单值(int / String)
@CsvSource多参数、可直接写成一行
@MethodSource复杂对象、需要构造逻辑的用例
@EnumSource遍历某个枚举的所有值
64 / 145
提示

参数化测试的用例名默认带下标(如 [2] -5),出问题时不易定位。加 @ParameterizedTest(name = "第 {index} 组: {0} 应被拒绝") 可以让报告直接说人话。

65 / 145
小节
八、测试替身辨析:Dummy / Stub / Spy / Mock / Fake
66 / 145

「测试替身」是一个统称,五种替身职责不同,面试常问:

67 / 145
对照表
替身一句话行为
Dummy占位用,从不真正被使用只为填满参数列表
Stub被调用时返回预设值只管「回答什么」
Spy包装真实对象,可选择性地打桩默认走真实逻辑,可局部替换
Mock记录调用,供事后验证关心「有没有被按约定调用」
Fake有真实行为的简化实现如内存版仓库
68 / 145

@Spy 的实际用法——想要真实逻辑,只替换其中一个方法:

69 / 145
代码对照
代码java
@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 应作为例外,而非常态。

70 / 145
小节
九、测试覆盖率:JaCoCo 与「覆盖率不是目标」
71 / 145

Maven 里加一个插件就能生成覆盖率报告:

72 / 145
xml
<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>
73 / 145

执行 mvn verify 后,报告落在 target/site/jacoco/index.html。

74 / 145
坑

覆盖率是「体检指标」,不是「绩效指标」。 为了凑数字去写「调一下方法、不做任何断言」的测试,覆盖率能到 100% 却毫无价值——它甚至连「方法被调用过」这个最低要求都只能勉强证明。真正该关注的是分支覆盖(if 的两条路都走了吗)和你最重要的业务逻辑是否被测到,而不是那个百分比。

75 / 145

不过「不设门槛」和「门槛 100%」这两种失败是同一个词,代价却完全相反。把这条线拖一遍,看它两端各自逼出什么样的测试代码:

76 / 145
参数调节台
调节台覆盖率门槛:设多少,就逼出什么样的测试
jacoco.check.branch-covered-ratio
60% 分支覆盖当前 0 – 100
常见线:新项目的主流区间
  • 60~70% 意味着主干分支基本被覆盖,剩下的多是 getter 与配置类
  • 想再往上,收益开始低于成本:你是在测框架还是在测业务?
  • 必须同时盯**分支覆盖**,行覆盖 70% 可能一条 if 都没进过 else
  • 建议与「每条用例至少一个断言」的评审规则配套
测试质量80%
回归防护70%
门槛定在「现在能守住、且逼人写断言」的那一档;数字只是刹车,不是成绩。
77 / 145
小节
十、上手实验:六个实验把这条链按一遍
78 / 145

前面写的全是纸面规则。下面六个实验分别回答:一个测试类到底按什么顺序跑、假对象是怎么被塞进去的、什么时候必须让真容器上场、断言失败的那句红字怎么读、切片测试到底拦住了哪一段,以及为什么「Failed to load ApplicationContext」不该出现在单元测试里。

79 / 145

进实验室之前,先把注解和它的职责配一遍——测试这块的注解不像 @Service 那样「写了就生效」,每一个都各管一件不同的事,配错一次就少踩一个坑:

80 / 145
配对闯关
闯关测试注解 ↔ 它到底替你做了什么已配对 0/7 · 配错 0
七组都是「写法 ↔ 职责」的硬映射。注意第 2 组与第 5 组——它们都会影响上下文缓存,第十节那张动图讲的就是这件事
先点左边一个
81 / 145

第一个是主角,四档对应 Mockito 的四种典型动作:run 看执行顺序(和第三节的骨架一一对照)、mock 看依赖被替换的瞬间、verify 看交互次数的记账、fail 直接给你一条真实的失败信息:

82 / 145
内核实验
TeaVM一个测试类的一生:从 @BeforeAll 到断言失败未启动
先「一个测试类的执行顺序」对齐第三节那张时间轴,再看「校验交互次数」理解 verify 到底在数什么;最后切「断言失败怎么读」,把那句红字抄进笔记
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
83 / 145

第二个解决新手最普遍的疑惑:@Mock 写在字段上,凭什么就有值了?这背后是一次装配,和你在第 6 篇学的注入是同一套时机模型:

84 / 145
内核实验
TeaVM被测对象是怎么拿到替身的未启动
切 setter / field 两档,对照第五节说的「@InjectMocks 优先尝试构造器注入」——三种方式决定了替身是在哪一步被填进去的
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
85 / 145

第三个用来确认第一节那条纪律:一旦你想验证的是「Spring 能不能装起来」「注解有没有真的生效」,就已经离开单元测试的地盘了。选 @SpringBootTest 启动 感受它多做了哪些事,再切「上下文缓存复用」看为什么第二次跑会快很多:

86 / 145
内核实验
TeaVM什么时候必须让真容器上场未启动
依次看 @SpringBootTest 启动 → 上下文缓存复用 → @WebMvcTest 切片,体会「集成测试的成本来自起容器」这句话
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
87 / 145

第四个回到起点:被测对象交给容器管之后,它的构造、初始化、销毁都不再由你的测试决定——这正是单元测试要绕开容器的原因,也是本节最后一个实验:

88 / 145
内核实验
TeaVM被测对象的一生:从构造到销毁未启动
切到「原型」,想想为什么单元测试要避免依赖容器生命周期
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
89 / 145

第五个补上切片测试那块空白:@WebMvcTest 到底拦住了哪一段。切 /users/42 看路径变量怎么绑成参数,再切 /nope 看 404 是从哪一层返回的——这些正是单元测试永远证明不了、而起全容器又太贵的东西:

90 / 145
内核实验
TeaVM切片测试守住的是这一段未启动
先路径变量、再列表、最后 404:三档走完,你会明白第十一节沙盘里「契约」那一档到底在测什么
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
91 / 145

第六个对着第十三节最后那条报错:Failed to load ApplicationContext。它出现在单元测试里,本质是「这个类不该起容器」;先切「启动失败怎么读」看清那一大坨的读法,再回来看你的测试类是不是混进了 @SpringBootTest:

92 / 145
内核实验
TeaVM当单元测试里混进了容器未启动
这一档演示上下文启动失败时那屏输出的结构:从下往上找 caused by,第一句业务相关行通常就是缺配置的那个 Bean
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
93 / 145

第三个实验里那句「第二次跑会快很多」,靠的是上下文缓存。这张动图把它的六个节点摊开——注意第 ⑤ 帧:多加一个 @MockitoBean 就会让 key 改变、于是再起一个容器。团队里「测试越加越慢」九成不是代码变多,而是这一格失控:

94 / 145
原理动画
动图 · 上下文缓存:谁复用、谁重建
动图 · 上下文缓存:谁复用、谁重建
95 / 145

复用率是一个真数值旋钮,而且它的成本藏在 CI 的墙上时钟里。拖一遍就明白为什么要「让集成测试类的配置尽量一致」:

96 / 145
参数调节台
调节台并行跑测试:线程数一上去,上下文与连接池就打架
junit.jupiter.execution.parallel.config.fixed.parallelism
4条线程当前 1 – 16
常见配置:纯单元测试收益明显
  • 4 条线程大约对应一台 CI 机器的一半核数
  • 需要同时开 @Execution(CONCURRENT) 或全局 mode=concurrent
  • 纯单元测试并行几乎无副作用,因为它们互不共享状态
  • 这一档能把几分钟的测试阶段压到一分钟内
CI 墙钟时间42%
上下文重建20%
先问「慢是来自于用例多,还是来自于反复起容器」——前者才考虑并行,后者要修的是配置一致性。
97 / 145

实验做到这里,可以换成命令行自己敲了。下面这台控制台连着浏览器里的同一个 Java 内核,回显全部由内核算出来——先 beans 看容器里装了什么,再把「打桩 → 验证 → 失败」这条链敲一遍:

98 / 145
内核控制台
99 / 145
说明

lab mock verify 和 lab mock fail 要连着敲——前者把「Mock 到底在数什么」打印出来,后者给一句真实的红字。对照第十三节那张表读,你会发现所有失败信息都只写着两件事:期望是什么、实际发生了几次。

100 / 145
小节
十一、沙盘:这段代码该用哪种测试
101 / 145

「该不该起容器」这个问题答错,代价有两种相反的方向:单元化不足→跑一次测试十分钟,没人愿意跑;过度集成→测试全绿但业务分支一个没测,改了照样炸。这个沙盘把「你想验证的约定是什么」做成开关,切一档就同屏看到选型、耗时和被证明的东西:

102 / 145
沙盘
沙盘该用哪种测试:先说想证明什么
运行结果
选型:纯 JUnit 5 + Mockito(不写 @SpringBootTest)
耗时:单个用例 2~5ms,全模块 1200 个用例 ≈ 8s
Repository / HTTP 客户端全部 @Mock
# 判据:结论只取决于你自己写的 if 与计算 → 单元
盲区:SQL 拼错、事务没生效,这里永远测不出来
金额计算、折扣叠加、状态流转、参数校验规则都属于这一格。这类逻辑分支多、变动频繁,正是单元测试回报最高的地方。
103 / 145
说明

四个选项的排序就是成本排序。先问「这条约定错了会怎样」,再问「最小能证明它的工具是谁」——而不是反过来先决定要不要起容器。第十二节第一道题会考的就是这条推理链。

104 / 145
小节
十二、随堂自测
105 / 145

先来一道热身题,考的是第十节 fail 那一档你会亲眼看到的报错:

106 / 145
随堂自测
随堂自测测试里写了 `verify(paymentClient, times(1)).pay(any());`,运行后报 `Wanted but not invoked ... However, there were exactly 2 interactions with this mock`。这句话的意思是?
先自己选一个,选中立刻告诉你对不对
107 / 145

再来一道综合题,把第二、五、六节串起来:

108 / 145
随堂自测
随堂自测要给「订单满 100 免运费」这条规则写单元测试,OrderService 依赖 OrderRepository、ShippingClient 和一个 `new BigDecimal(...)` 的金额计算。最合理的做法是?
先自己选一个,选中立刻告诉你对不对
109 / 145
小节
十三、常见报错速查
110 / 145

测试变红不可怕,可怕的是读不懂那几行红字。下面这些片段都能原样复制去搜索,右侧给出「人话翻译」。

111 / 145
对照表
报错原文(片段)真实原因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 关闭本篇第六节
112 / 145
提示

读红字时只看两段就够了——冒号前的异常类名告诉你「谁在抱怨」(misusing 属于打桩姿势问题,comparison 属于断言问题,wanted 属于验证问题),冒号后的第一句告诉你「差在哪」。别从第三行开始逐字读堆栈。

113 / 145

表里倒数第二条 Failed to load ApplicationContext 值得单独练一次:它有一屏长的输出、连着拖垮一片用例,而真正的原因常常只是「这个类压根不该起容器」。先别看答案,点出你认为的凶手行:

114 / 145
报错急救
报错急救IllegalStateException: Failed to load ApplicationContext
一个「单元测试」起不来容器:一屏报错拖垮整个模块

给 OrderService 加了一条测试用例,本地跑别的类都正常,只有这个类红了——而且同一批有 14 条用例一起红。

java.lang.IllegalStateException: Failed to load ApplicationContext for [WebMergedContextConfiguration@3f49dab testClass = com.bee.order.OrderServiceTest, locations = [], classes = [com.bee.order.OrderApplication], contextInitializerClasses = [], activeProfiles = [], propertySourceDescriptors = []]
at org.springframework.test.context.cache.DefaultCacheAwareContextLoaderDelegate.loadContext(DefaultCacheAwareContextLoaderDelegate.java:145)
at org.springframework.test.context.support.DefaultTestContext.getApplicationContext(DefaultTestContext.java:124)
at org.springframework.test.context.web.ServletTestExecutionListener.setUpRequestContextIfNecessary(ServletTestExecutionListener.java:191)
at org.springframework.test.context.TestContextManager.prepareTestInstance(TestContextManager.java:241)
at org.springframework.test.context.junit.jupiter.SpringExtension.postProcessTestInstance(SpringExtension.java:158)
Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'dataSource' defined in class path resource [org/springframework/boot/autoconfigure/jdbc/DataSourceConfiguration$Hikari.class]: Failed to instantiate [com.zaxxer.hikari.HikariDataSource]: Factory method 'dataSource' threw exception with message: Failed to determine a suitable driver class
at org.springframework.beans.factory.support.ConstructorResolver.instantiate(ConstructorResolver.java:648)
Caused by: org.springframework.boot.autoconfigure.jdbc.DataSourceProperties$DataSourceBeanCreationException: Failed to determine a suitable driver class
点你认为的「凶手行」(可反复试)
不会也没关系:先猜异常名,再猜哪一行在做决定。
115 / 145
小节
十四、动手练习
116 / 145
小节
第一档 · 照做
117 / 145

建一个 src/test/java/com/example/demo/service/FreightRuleTest.java,把「满 100 免运费」这条规则测到能防住误改。完整可跑代码 + 预期输出:

118 / 145
java
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; }}
119 / 145
java
package com.example.demo.service;public interface ShippingClient {    BigDecimal quote(long orderId);   // 真实场景要打外部物流系统,所以必须 Mock}
120 / 145
java
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());    }}
121 / 145
java
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());   // 关键:证明「根本没去询价」    }}
122 / 145

预期输出(mvn test 或 IDEA 里直接跑这个类):

123 / 145
text
[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
124 / 145

两个观察点:① 三条参数化用例共用一段代码,边界值一眼可见;② 第二条用例里的 verify(never()) 才是这条规则的真正防线——如果哪天有人改成「先询价再判断满减」,功能结果不变但外部调用翻倍,这个测试立刻红。

125 / 145
小节
第二档 · 变体
126 / 145

目标:亲手制造并读懂第十三节那条最好笑的报错。

127 / 145

提示:在 should_not_call_shipping_client_when_amount_reaches_threshold 之前多加一行永远不会被用到的打桩:when(shippingClient.quote(99L)).thenReturn(new BigDecimal("12.00"));,然后重跑整个类。

128 / 145

你会观察到:两个用例都变成红色,报错是 UnnecessaryStubbingException,并且消息里直接点名是哪一行、哪个方法。这时候有两条路——把多余打桩删掉(正解),或者在类上加 @MockitoSettings(strictness = Strictness.LENIENT)(临时止痛)。请两种都试一次,然后自己解释为什么 Mockito 默认要这么「麻烦」:因为无用的打桩会让下一个读测试的人以为「这个依赖在这里是有意义的」,久而久之没人敢删代码。

129 / 145
小节
第三档 · 造一个
130 / 145

给一个真实的小服务补齐测试:CouponService.applyDiscount(Order order, Coupon coupon),规则是「满减券按门槛减免、百分比券封顶 50 元、过期券直接拒绝」。

131 / 145

验收清单:

132 / 145
  • [ ] 至少 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 的哪一段,且三段之间没有互相穿插
133 / 145
小节
十五、三个坑与一张决策卡
134 / 145
坑

@Mock 注入失败,多半是漏了 @ExtendWith(MockitoExtension.class);若用 @MockBean,那属于集成测试,会启动 Spring 上下文,别在纯单元测试里用。

135 / 145
坑

测试相互依赖。JUnit 不保证执行顺序,任何「A 跑过 B 才能过」的测试都是定时炸弹;用 @BeforeEach 让每个用例拿到干净状态。

136 / 145
坑

断言异常消息过度耦合。hasMessage("用户[1]不存在") 这种把文案写死的断言,改一次提示语就红一片。用 hasMessageContaining("不存在") 这类宽松匹配,让测试盯「行为」而不是「文案」。

137 / 145
决策
决策团队新人问:「测试类和被测类要不要同包同名?放到别的包里会不会更整齐?」
138 / 145
小节
十六、要点自查
139 / 145
自检

不看资料,画出第三节的执行顺序:@BeforeAll → (@BeforeEach → @Test → @AfterEach)× N → @AfterAll。并且说出为什么 @BeforeAll 必须是 static。

140 / 145
自检

when(...) 和 verify(...) 分别在回答什么问题?答案应是「返回什么」与「有没有被这样调用」,两者不可互换。

141 / 145
自检

UnnecessaryStubbingException、Wanted but not invoked ... 0 interactions、Argument(s) are different 这三句各指向什么病根?说不出第三条就回去看 equals 那段。

142 / 145
自检

哪些东西该 Mock、哪些不该?标准答案只有一句话——出问题时会由我在单元测试里定位的吗?

143 / 145
自检

@Mock 和 @MockitoBean 的区别是什么?前者属于纯单元测试(毫秒级、不起容器),后者属于 Spring 集成测试(会启动上下文)。

144 / 145
口诀

JUnit 管顺序,Mockito 管替身;打桩答「返回什么」,verify 查「有没有干」;值对象直接 new,边界值参数化;红了先看类名再读首句,全绿也别忘了一句 verify(never())。

145 / 145
总结

把这篇压成几句话——单元测试的价值在于逼你写出低耦合、可拼装的代码,并给你毫秒级的反馈;JUnit 5 管生命周期(@BeforeEach/@BeforeAll)与断言,Mockito 管打桩(when)与验证(verify/ArgumentCaptor);Given-When-Then 三段式 + should_xxx_when_yyy 命名让测试自解释;只在真正的边界上 Mock,值对象与纯函数直接 new;参数化测试消灭重复用例,@Spy 是例外而非常态;覆盖率是体检指标,别把它当绩效目标。掌握这些,你的测试才会成为资产,而不是负担。