Skip to content

Mockito 进阶:ArgumentCaptor 与依赖注入 ​

T1-011建议学进阶约 60 分钟JDK 21测试专项 A:单元测试 · Mockito

1. 本节定位 ​

《测试替身全景:Stub、Spy、Fake 与 Mock 边界》 把替身的类型讲清了。本节解决三个具体问题:setUp 写得太啰嗦、参数对象的断言太粗、返回值需要动态计算。

这三个问题的答案分别是 @Mock / @InjectMocks、ArgumentCaptor、thenAnswer。它们能把测试从"能用"提升到"精确"。

2. 学习目标 ​

完成本节后,你应该能够:

  • 用 @ExtendWith(MockitoExtension.class) + @Mock / @InjectMocks 替代手写 setUp;
  • 说清严格桩(STRICT_STUBS)带来的好处,并看懂 UnnecessaryStubbingException;
  • 用 ArgumentCaptor 捕获参数对象,逐个字段断言;
  • 用 getAllValues() 校验多次调用的参数序列;
  • 用 thenAnswer 让返回值依赖入参;
  • 用 doThrow / doReturn 给 void 方法与 spy 打桩。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

看一段能工作的测试,找出它的三个问题:

java
class UserServiceTest {
    private UserRepository repository;
    private Notifier notifier;
    private UserService service;

    @BeforeEach
    void setUp() {
        repository = mock(UserRepository.class);      // ① 每个测试类都要抄一遍
        notifier = mock(Notifier.class);
        service = new UserService(repository, notifier);
    }

    @Test
    void rename() {
        when(repository.findById("u1")).thenReturn(Optional.of(new User("u1", "旧名", "a@b.c")));
        service.rename("u1", "新名");

        verify(repository).save(new User("u1", "新名", "a@b.c"));   // ② 靠 equals 整体比对
    }
}

问题 ①:样板代码。每个测试类都要写三行 mock 声明 + 三行 setUp 赋值。

问题 ②:断言太粗。save(new User(...)) 依赖 User 的 equals。如果 User 是个没重写 equals 的普通类,这行永远不相等;即使重写了,失败时你也只知道"对象不等",不知道是哪个字段错了。

问题 ③:静态返回值不够用。当返回值需要根据入参计算时,thenReturn 写不出来。

5. 核心概念 ​

5.1 注解式初始化:@Mock / @InjectMocks ​

java
@ExtendWith(MockitoExtension.class)
class AdvancedMockitoTest {
    @Mock
    private UserRepository repository;      // 自动创建 mock

    @Mock
    private Notifier notifier;

    @InjectMocks
    private UserService service;            // 自动创建实例,并把上面的 mock 注入进去
}

三个注解的分工:

注解做什么
@ExtendWith(MockitoExtension.class)让 JUnit 5 在测试前后自动初始化与校验 mock
@Mock创建 mock 并注入到字段
@InjectMocks创建被测对象,并把 @Mock 字段注入进去

@InjectMocks 的注入顺序:

  1. 构造器注入(参数最多的那个构造器)← 最推荐,也最可靠;
  2. setter 注入;
  3. 字段注入。

实践建议:生产代码用构造器注入(T1-003 讲过它对可测试性的价值),这样 @InjectMocks 走的就是第一条路径,最不容易出意外。

@ExtendWith 忘了写会怎样:@Mock 字段是 null,测试在 when(repository.findById(...)) 处抛 NullPointerException。这是最常见的"注解不生效"现场。

5.2 严格桩:Mockito 帮你找"僵尸桩" ​

MockitoExtension 默认开启 STRICT_STUBS,它做两件事:

  1. 未使用的桩 → 测试失败(UnnecessaryStubbingException);
  2. 参数不匹配的桩 → 测试失败(提示你可能写错了匹配器)。
java
@Test
void rename() {
    when(repository.findById("u1")).thenReturn(Optional.of(user));
    when(notifier.isEnabled("u1")).thenReturn(true);      // ← 打了桩但永远没被调用

    service.rename("u1", "新名");

    // STRICT_STUBS 会让这个测试失败:
    // UnnecessaryStubbingException: Unnecessary stubbings detected
}

为什么这是好事:

问题没有严格桩有严格桩
复制粘贴留下的多余桩悄悄留着,越积越多立刻失败
桩的参数写错了(永远匹配不上)测试仍然绿,但"保护力"是假的失败并提示
桩打得太多、掩盖了真实逻辑无人察觉被迫精简

遇到 UnnecessaryStubbingException 怎么办:

  1. 首选:删掉那个桩(它确实没用了);
  2. 如果桩是"为某个不常走的分支准备"的:把它挪到真正需要它的测试方法里(这是更清晰的做法);
  3. 实在需要放宽:在字段上用 @MockitoSettings(strictness = Strictness.LENIENT) 或对单个桩用 lenient()——但先问自己"为什么需要它"。

经验之谈:严格桩一开始会让人烦,但它是**把"假绿的测试"变成"真红的测试"**的机制。不要为了消灭报错而全局关掉它。

5.3 ArgumentCaptor:捕获参数,逐个字段断言 ​

java
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
verify(repository).save(captor.capture());

User saved = captor.getValue();                  // 拿到实际传进去的对象
assertThat(saved.id()).isEqualTo("u1");
assertThat(saved.name()).isEqualTo("新名");
assertThat(saved.email()).isEqualTo("u1@test.com");

它比 verify(repo).save(new User(...)) 强在哪:

整体比对ArgumentCaptor
依赖参数的 equals 实现不依赖
失败信息"两个对象不等""name 期望 X,实际 Y"
能力只能比对已知值能检查运行时才知道的值

第三点很关键:有些参数值在测试里写不出来(比如"当前时间戳""自动生成的 id")。用 captor 拿到后,可以断言它的性质而不是具体值:

java
User saved = captor.getValue();
assertThat(saved.id()).isNotBlank();
assertThat(saved.createdAt()).isBefore(Instant.now());

多次调用用 getAllValues():

java
verify(repository, times(2)).save(captor.capture());

assertThat(captor.getAllValues())
        .extracting(User::name)
        .containsExactly("新名一", "新名二");

注意顺序:getAllValues() 按调用顺序返回,所以能验证"先保存了 A 再保存了 B"。

惯用位置:ArgumentCaptor.forClass(...) 通常写在 verify 附近(就地创建),而不是在 @BeforeEach 里——这样读者一眼就知道它捕获的是哪次调用。

5.4 thenAnswer:返回值依赖入参 ​

java
when(repository.findById(anyString())).thenAnswer(invocation -> {
    String id = invocation.getArgument(0);              // 取第 0 个参数
    return Optional.of(new User(id, "名字", "x@y.z"));
});

与 thenReturn 的区别:

thenReturnthenAnswer
返回值固定值每次调用时计算
能拿到入参✗✓(invocation.getArgument(n))
能拿到调用次数✗✓(invocation.getArguments().length 或自己计数)
适用大多数场景需要"根据输入给输出"时

典型的三个用例:

  1. 回显入参:把 id 拼进返回值(本节代码的写法);
  2. 模拟递增/递减:每次调用返回不同的值(如"第一次库存 10,第二次 9");
  3. 模拟抛异常:根据参数决定抛什么异常。

提醒:thenAnswer 里的逻辑如果复杂到需要注释,说明你正在用 mock 实现业务逻辑——这通常是该换 Fake 的信号(T1-010)。

5.5 doThrow / doReturn:给 void 方法与 spy 打桩 ​

java
doThrow(new IllegalStateException("数据库不可用")).when(repository).save(any());

为什么需要 do... 系列:when(mock.voidMethod()) 这种写法语法上就编译不过(void 方法没有返回值可以传给 when)。

场景写法
对 void 方法抛异常doThrow(ex).when(mock).voidMethod(args)
对 void 方法什么都不做doNothing().when(mock).voidMethod(args)
对 spy 打桩(避免真实逻辑先执行)doReturn(value).when(spy).method(args)
连续行为doThrow(ex).doNothing().when(mock).voidMethod(args)

为什么对 spy 要用 doReturn 而不是 when:

java
// 危险:spy.method() 会先真的执行一次!
when(spy.method()).thenReturn(value);

// 安全:不会执行真实逻辑
doReturn(value).when(spy).method();

原因:when(spy.method()) 里 spy.method() 是被真实调用的——如果它有副作用(写文件、发请求),副作用已经发生了。

记忆点:when(...) 是"先执行、后替换",do...when(...) 是"先替换、后执行"。

5.6 连续桩:让同一方法先后表现不同 ​

java
when(repository.findById("u1"))
        .thenReturn(Optional.of(user))
        .thenThrow(new IllegalStateException("连接中断"));

语义:第一次调用返回对象,第二次抛异常,之后继续抛。

注意:它适合"验证代码在第二次调用失败时的行为"(如重试、熔断),但不要用它来模拟"对象的状态变化"——那种情况用 Fake 更真实(T1-010)。

6. 递进示例 ​

6.1 最小示例:注解替换 setUp ​

java
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
    @Mock private UserRepository repository;
    @InjectMocks private UserService service;
}

6 行 setUp 变成 3 行注解,而且新增依赖时不用改 setUp。

6.2 业务示例:捕获并逐字段断言 ​

见 5.3 节。这是"参数是对象"时的标准做法。

6.3 反例:在 thenAnswer 里堆业务逻辑 ​

java
when(pricingClient.quote(any())).thenAnswer(invocation -> {
    Order order = invocation.getArgument(0);
    // 反例:在测试里重写了定价规则
    long total = order.items().stream().mapToLong(Item::price).sum();
    if (order.member()) { total -= 500; }
    return new Quote(total);
});

问题:这段代码重新实现了被测系统的一部分逻辑。如果真实定价规则改了,这个桩不会变——测试仍然绿,但它验证的是一份过期的副本。

修复:用固定的期望值(thenReturn(new Quote(9_000L))),或者用 Fake 承载真实逻辑并单独测试那个 Fake。

7. 完整可运行代码 ​

文件:labs/testing-unit/src/test/java/cn/kb/learn/testing/AdvancedMockitoTest.java(节选)

java
@ExtendWith(MockitoExtension.class)
@DisplayName("Mockito 进阶")
class AdvancedMockitoTest {
    @Mock
    private UserRepository repository;

    @Mock
    private Notifier notifier;

    @InjectMocks
    private UserService service;

    @Test
    @DisplayName("@Mock + @InjectMocks:省去手写构造与 setUp")
    void injection_replaces_manual_setup() {
        when(repository.findById("u1"))
                .thenReturn(Optional.of(new User("u1", "旧名", "u1@test.com")));

        assertThat(service.rename("u1", "新名")).isEqualTo("新名");
        verify(notifier).send("u1", "您的名字已更改为 新名");
    }

    @Test
    @DisplayName("ArgumentCaptor:捕获被保存的对象,逐个字段断言")
    void argument_captor_inspects_saved_object() {
        when(repository.findById("u1"))
                .thenReturn(Optional.of(new User("u1", "旧名", "u1@test.com")));

        service.rename("u1", "新名");

        ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
        verify(repository).save(captor.capture());

        User saved = captor.getValue();
        assertThat(saved.id()).isEqualTo("u1");
        assertThat(saved.name()).isEqualTo("新名");
        assertThat(saved.email()).isEqualTo("u1@test.com");
    }

    @Test
    @DisplayName("thenAnswer:根据传入参数动态生成返回值")
    void then_answer_computes_response_from_arguments() {
        when(repository.findById(anyString())).thenAnswer(invocation -> {
            String id = invocation.getArgument(0);
            return Optional.of(new User(id, "名字", "x@y.z"));
        });

        assertThat(service.rename("u42", "新名")).isEqualTo("新名");
        verify(repository).save(new User("u42", "新名", "x@y.z"));
    }

    @Test
    @DisplayName("doThrow:模拟依赖失败,验证异常传播与后续动作被跳过")
    void do_throw_simulates_dependency_failure() {
        when(repository.findById("u1"))
                .thenReturn(Optional.of(new User("u1", "旧名", "u1@test.com")));
        doThrow(new IllegalStateException("数据库不可用")).when(repository).save(any());

        assertThatThrownBy(() -> service.rename("u1", "新名"))
                .isInstanceOf(IllegalStateException.class)
                .hasMessage("数据库不可用");

        verify(notifier, never()).send(anyString(), anyString());
    }

    @Test
    @DisplayName("连续桩:第一次正常返回,第二次抛异常")
    void consecutive_stubbing_changes_behavior_between_calls() {
        when(repository.findById("u1"))
                .thenReturn(Optional.of(new User("u1", "旧名", "u1@test.com")))
                .thenThrow(new IllegalStateException("连接中断"));

        assertThat(service.rename("u1", "新名")).isEqualTo("新名");

        assertThatThrownBy(() -> service.rename("u1", "又一个名字"))
                .isInstanceOf(IllegalStateException.class)
                .hasMessage("连接中断");
    }

    @Test
    @DisplayName("ArgumentCaptor 配合多次调用:捕获全部参数")
    void argument_captor_collects_all_invocations() {
        when(repository.findById(anyString()))
                .thenReturn(Optional.of(new User("u1", "旧名", "u1@test.com")));

        service.rename("u1", "新名一");
        service.rename("u1", "新名二");

        ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
        verify(repository, times(2)).save(captor.capture());

        assertThat(captor.getAllValues())
                .extracting(User::name)
                .containsExactly("新名一", "新名二");
    }
}

运行命令:

powershell
cd labs
./mvnw -pl testing-unit test -Dtest=AdvancedMockitoTest

预期输出:

text
Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.458 s
  -- in cn.kb.learn.testing.AdvancedMockitoTest

注意那个 2.458 秒:这是本类第一次运行时 Mockito 初始化 inline mock maker 的开销(JVM 层的字节码增强)。同一个 JVM 里后续的测试类会快得多——这也解释了为什么"单独跑一个 Mockito 测试类"看起来比"跑整个套件"慢。

六种写法的分工:

测试方法演示的 API解决的问题
injection_replaces_manual_setup@Mock / @InjectMocks消灭样板代码
argument_captor_inspects_saved_objectArgumentCaptor精确断言参数对象
then_answer_computes_response_from_argumentsthenAnswer返回值依赖入参
do_throw_simulates_dependency_failuredoThrow给 void 方法模拟失败
consecutive_stubbing_changes_behavior_between_calls连续桩模拟"第二次失败"
argument_captor_collects_all_invocationsgetAllValues()校验调用序列

8. Debug 观察点 ​

  1. 在 argument_captor_inspects_saved_object 的 captor.getValue() 处断点,展开 saved:这就是被测代码真实构造出来的对象——不是你在测试里 new 的那个;
  2. 在 thenAnswer 的 lambda 里断点,观察 invocation 对象:展开 getArguments() 能看到完整的入参数组;
  3. 把 @ExtendWith(MockitoExtension.class) 注释掉,重跑,观察 NullPointerException——@Mock 字段是 null。这是"注解不生效"的最常见现场;
  4. 在 doThrow(...).when(repository).save(any()) 处 F7 进入,感受一下它的执行是"先配置好,等调用时再抛";
  5. 给某个测试加一个用不到的桩,观察 UnnecessaryStubbingException 的完整信息——它会指出哪一行桩是多余的。

9. 常见错误 ​

错误 1:忘了 @ExtendWith(MockitoExtension.class) ​

java
class MyTest {
    @Mock private UserRepository repository;    // 没有 Extension → 这个字段是 null
}

症状:NullPointerException 出现在 when(repository.xxx()) 那一行。修复:加 @ExtendWith(MockitoExtension.class)。

错误 2:UnnecessaryStubbingException ​

text
org.mockito.exceptions.misusing.UnnecessaryStubbingException:
Unnecessary stubbings detected.
Clean & maintainable test code requires zero unnecessary code.

原因:打了桩但没被调用(常见于复制粘贴、或桩的参数与其他测试不匹配)。

修复顺序:① 删掉它;② 挪到真正需要的测试方法里;③ 确认需要放宽时才用 lenient()。

不要第一反应就是全局关掉严格桩——那等于把体检报告撕掉。

错误 3:ArgumentCaptor.forClass 用在泛型上 ​

java
ArgumentCaptor<List<User>> captor = ArgumentCaptor.forClass(List.class);   // 编译警告

修复:用 @Captor 注解(自动处理泛型):

java
@Captor
private ArgumentCaptor<List<User>> usersCaptor;

错误 4:对 spy 用 when(...) 而不是 doReturn(...) ​

java
when(spy.method()).thenReturn(value);        // spy.method() 已经真实执行了一次!

修复:doReturn(value).when(spy).method();

错误 5:用 thenAnswer 实现业务逻辑 ​

见 6.3 节。判断标准:thenAnswer 里的代码如果超过三行,或者需要理解业务规则才能看懂,就该换成 Fixed Stub 或 Fake。

错误 6:verify 的参数用了对象,但对象没重写 equals ​

java
verify(repository).save(new User("u1", "新名", "a@b.c"));    // User 没有 equals → 永远不相等

修复:要么确保被比对的类型正确实现了 equals(record 自动有 ✓),要么改用 ArgumentCaptor 逐字段断言——后者更适合"想知道哪里不对"的场景。

10. 动手实验 ​

目标:把 《测试替身全景:Stub、Spy、Fake 与 Mock 边界》 的测试升级成注解式 + 精确断言。

步骤:

  1. 运行第 7 节的命令,核对 6 个测试通过;
  2. 把 UserServiceTest(T1-003 写的)改造成 @ExtendWith(MockitoExtension.class) + @Mock / @InjectMocks 版本,对比改造前后的行数;
  3. 给改造后的 should_rename_and_notify_when_user_exists 加上 ArgumentCaptor,逐个字段断言被保存的 User;
  4. 故意加一个用不到的桩,观察 UnnecessaryStubbingException,然后删掉它;
  5. 用 thenAnswer 写一个测试:findById 对任何 id 都返回"该 id 对应的用户"(回显 id),验证 rename 能正确处理任意 id。

验收方式:能说出第 4 步失败信息里指出的"多余桩"是哪一行,以及第 5 步为什么需要 thenAnswer 而不是 thenReturn。

扩展任务:给一个"生成订单号"的方法写测试:订单号包含时间戳(无法预先知道具体值)。用 ArgumentCaptor + AssertJ 的 matches(...) 断言它的格式(如 ^ORD-\d{8}-\d{4}$)——这是"断言性质而不是值"的典型写法。

11. 练习题 ​

基础题

  1. 判断:@InjectMocks 会优先使用构造器注入。(对 / 错)
  2. 填空:捕获方法参数对象用 ______;让返回值依赖入参用 ______。
  3. 简答:严格桩(STRICT_STUBS)解决什么问题?

代码阅读题

java
@ExtendWith(MockitoExtension.class)
class DemoTest {
    @Mock private Notifier notifier;
    @InjectMocks private UserService service;

    @Test
    void test() {
        when(notifier.isEnabled("u1")).thenReturn(true);

        service.delete("u404");        // 用户不存在,会抛异常
    }
}
  1. 这段测试的问题是什么?会报什么错?
  2. 如果确实想保留 isEnabled 的桩(因为另一个测试需要它),应该怎么做?

编程题

  1. 为"文章发布服务"写进阶测试(被测类是 labs 里的 ArticleService):
    • 用 @Mock / @InjectMocks 初始化;
    • 用 ArgumentCaptor 捕获 repository.save(...) 的参数,断言 id、title、content、published 四个字段;
    • 用 thenAnswer 让 findById 对任意 id 返回"该 id 的未发布文章";
    • 用 doThrow 模拟保存失败,验证异常传播且没有任何副作用(如通知未发出)。

综合任务

  1. 给"用户改名"设计一套完整的进阶测试:
    • 用 ArgumentCaptor 断言:新用户对象保留了原有的 email,只改了 name(这是容易写错的地方);
    • 用 thenAnswer 模拟"仓储返回的用户对象带版本号",断言改名后版本号保持不变;
    • 用连续桩模拟"第一次查询成功,第二次查询超时",验证服务的重试或异常行为(如果 UserService 没有重试逻辑,先用 RetryTemplate 包一层);
    • 说明:这套测试里哪一条断言最有价值?(提示:想一想"如果实现写错了,哪条断言能抓到")
分级提示

提示 1(方向):第 4 题的关键是"打了桩但没用到"——delete("u404") 在 isEnabled 之前就抛异常了。

提示 2(关键点):第 6 题的 Article 是 record,可以用 extracting 断言多个字段。

提示 3(接近答案):第 7 题的"保留 email / 保留版本号"这类断言最有价值——它们验证的是"改名操作不该顺手清掉别的字段",这是真实 bug 的高发区。

12. 参考答案 ​

基础题
  1. 对。@InjectMocks 的注入顺序是:构造器(参数最多的)→ setter → 字段。所以生产代码用构造器注入时,@InjectMocks 最可靠。
  2. ArgumentCaptor;thenAnswer。
  3. 它解决**"僵尸桩"**问题:打了桩却没被使用(因为复制粘贴、或参数不匹配)时,直接让测试失败。这防止测试积累无意义的配置——那些桩不仅增加维护成本,还会给人"这个场景被覆盖了"的错觉。
代码阅读题(第 4、5 题)

第 4 题:会报 UnnecessaryStubbingException。

service.delete("u404") 里,"用户不存在"的判断发生在调用 notifier 之前就抛异常了,所以 isEnabled("u1") 的桩从未被使用。

同时:这个测试本身也不完整——delete 抛出的 IllegalArgumentException 没有被断言,它会作为"未预期的异常"让测试失败。

正确写法:

java
@Test
void throws_when_user_not_found() {
    when(repository.findById("u404")).thenReturn(Optional.empty());

    assertThatThrownBy(() -> service.delete("u404"))
            .isInstanceOf(IllegalArgumentException.class);
    verifyNoInteractions(notifier);
}

第 5 题:把它挪到真正需要它的测试方法里。

java
@Test
void sends_notification_when_enabled() {
    when(notifier.isEnabled("u1")).thenReturn(true);      // 只有这里需要它
    // ...
}

为什么不用 lenient() 解决:lenient() 会让测试对"桩是否有效"彻底失去检查。把桩放在需要它的地方,本身就是更好的测试设计——它明确了"这个桩和这个场景相关"。

只有在一种情况下才考虑 lenient():桩确实被多个测试共享、且必须放在 @BeforeEach 里(罕见)。

编程题(第 6 题)
java
package cn.kb.learn.testing;

import org.junit.jupiter.api.DisplayName;
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 java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
@DisplayName("文章发布服务:进阶断言")
class ArticleServiceAdvancedTest {
    @Mock
    private ArticleRepository repository;

    @InjectMocks
    private ArticleService service;

    @Test
    @DisplayName("ArgumentCaptor:断言保存的文章四个字段")
    void captures_saved_article() {
        when(repository.findById("a1")).thenReturn(Optional.of(new Article("a1", "旧标题", "旧内容", false)));

        service.publish("a1", "新标题", "新内容");

        ArgumentCaptor<Article> captor = ArgumentCaptor.forClass(Article.class);
        verify(repository).save(captor.capture());

        assertThat(captor.getValue())
                .extracting(Article::id, Article::title, Article::content, Article::published)
                .containsExactly("a1", "新标题", "新内容", true);
    }

    @Test
    @DisplayName("thenAnswer:任意 id 都返回该 id 的未发布文章")
    void then_answer_echoes_id() {
        when(repository.findById(anyString())).thenAnswer(invocation -> {
            String id = invocation.getArgument(0);
            return Optional.of(new Article(id, "标题", "内容", false));
        });

        Article published = service.publish("a-999", "新标题", "新内容");

        assertThat(published.id()).isEqualTo("a-999");
        assertThat(published.title()).isEqualTo("新标题");
    }

    @Test
    @DisplayName("doThrow:保存失败时异常传播,且不会返回已发布对象")
    void propagates_save_failure() {
        when(repository.findById("a1")).thenReturn(Optional.of(new Article("a1", "标题", "内容", false)));
        doThrow(new IllegalStateException("磁盘写满")).when(repository).save(any());

        assertThatThrownBy(() -> service.publish("a1", "新标题", "新内容"))
                .isInstanceOf(IllegalStateException.class)
                .hasMessage("磁盘写满");

        verify(repository, never()).delete(any());
    }
}

注意第一条断言的写法:用 AssertJ 的 extracting(...).containsExactly(...) 一次校验四个字段,失败信息会精确指出是哪个字段不对——这比四次单独的 assertThat 更紧凑,也比 isEqualTo 整体比对更精确。

第三条测试的一个细节:verify(repository, never()).delete(any()) 这种"验证没有副作用"的断言,需要生产类确实有 delete 方法才写得出。如果没有,就换成验证"其他方法没被调用"——关键是让断言覆盖"不该发生的事"。

综合任务(第 7 题)
java
package cn.kb.learn.testing;

import org.junit.jupiter.api.DisplayName;
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 java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
@DisplayName("用户改名:保留不该变的字段")
class RenamePreservesOtherFieldsTest {
    @Mock
    private UserRepository repository;

    @Mock
    private Notifier notifier;

    @InjectMocks
    private UserService service;

    @Test
    @DisplayName("改名只动 name,email 原样保留")
    void rename_keeps_email() {
        when(repository.findById("u1"))
                .thenReturn(Optional.of(new User("u1", "旧名", "keep@me.com")));

        service.rename("u1", "新名");

        ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
        verify(repository).save(captor.capture());

        User saved = captor.getValue();
        assertThat(saved.name()).isEqualTo("新名");
        assertThat(saved.email()).isEqualTo("keep@me.com");
        assertThat(saved.id()).isEqualTo("u1");
    }

    @Test
    @DisplayName("thenAnswer 回显:任意 id 都能改名,且 id 不被改动")
    void rename_keeps_id_for_any_user() {
        when(repository.findById(anyString())).thenAnswer(invocation ->
                Optional.of(new User(invocation.getArgument(0), "旧名", "x@y.z")));

        service.rename("u-777", "新名");

        ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
        verify(repository).save(captor.capture());
        assertThat(captor.getValue().id()).isEqualTo("u-777");
    }

    @Test
    @DisplayName("用户不存在时不保存、不通知")
    void missing_user_has_no_side_effects() {
        when(repository.findById("u404")).thenReturn(Optional.empty());

        org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class,
                () -> service.rename("u404", "新名"));

        verify(repository, org.mockito.Mockito.never()).save(org.mockito.ArgumentMatchers.any());
        verify(notifier, org.mockito.Mockito.never()).send(anyString(), anyString());
    }
}

哪条断言最有价值:

assertThat(saved.email()).isEqualTo("keep@me.com") 这一类"保留字段"的断言最有价值。

理由是它对应了真实的高频 bug:

java
// 一个很自然但错误的实现
User renamed = new User(user.id(), newName, "");        // 顺手把 email 清空了
// 或者
User renamed = new User(user.id(), newName, null);      // 忘了传 email

这类 bug 在"只断言 name 变了"的测试里完全不会被发现——因为断言只看了 name。

由此得出一条通用的断言设计原则:

对于"修改类"操作,除了断言"该变的变了",还要断言"不该变的没变"。

这条原则适用范围很广:改密码不该影响用户名、改地址不该清空手机号、更新配置不该重置默认值。测试的价值往往就在于守住这些"没想到会变"的地方——这正是"回归测试"的字面含义。

13. 掌握标准 ​

  • [ ] 能用 @Mock / @InjectMocks 替代手写 setUp;
  • [ ] 知道 @ExtendWith 忘了写会导致什么现象;
  • [ ] 能看懂并正确处理 UnnecessaryStubbingException;
  • [ ] 会用 ArgumentCaptor(含 getAllValues())做精确的参数断言;
  • [ ] 会用 thenAnswer 让返回值依赖入参;
  • [ ] 知道对 void 方法与 spy 该用 do...when(...),并说出原因。

14. 扩展资源 ​

15. 下一步学习 ​

《何时不该 Mock:过度 Mock 反模式》:Mock 用多了,测试会变成"实现细节的看守人"。

后续知识

暂无

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引