Skip to content

何时不该 Mock:过度 Mock 反模式 ​

T1-012必学进阶约 45 分钟JDK 21测试专项 A:单元测试 · Mockito

1. 本节定位 ​

前三节讲了"怎么用 Mock"。这一节讲**"什么时候该停手"**——它是 Mockito 系列里最重要的一节。

因为过度 Mock 的后果很隐蔽:测试是绿的、覆盖率是高的、CI 是通的,但它什么也没保护住。等到线上出问题、你回头找那个"本该拦住它的测试"时,才发现它验证的只是自己写的桩。

2. 学习目标 ​

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

  • 说出五种过度 Mock 的反模式,并写出对应的正例;
  • 用一个标准判断"测试是否绑定了实现细节";
  • 区分"可观察行为"与"实现细节";
  • 说清 verify 什么时候是必要的、什么时候是过度约束;
  • 在写测试前先问一句"这个测试要验证的业务规则是什么"。

3. 前置知识检查 ​

  • 会用 Mockito 的三件套与注解吗?(T1-003、T1-011)
  • 知道五种测试替身的边界吗?(T1-010)

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

先看一个"绿色但零保护力"的测试:

java
@Test
void anti_pattern_mocking_a_pure_calculation() {
    OrderCalculator calculator = mock(OrderCalculator.class);
    when(calculator.payableInCents(5_000L, 2)).thenReturn(9_000L);

    assertThat(calculator.payableInCents(5_000L, 2)).isEqualTo(9_000L);
}

这个测试会通过,它看起来在测"满减计算"。但实际上:

  • 真实的 OrderCalculator 一行代码都没有执行;
  • 断言验证的是"打桩时写下的 9000"——你在用假答案测试假答案;
  • 如果有人把 payableInCents 的实现改成 return 0;,这个测试仍然绿。

更麻烦的是:它会计入覆盖率,让 JaCoCo 报告看起来"被覆盖了"——用一个假的绿点掩盖了真实的空白。

这就是本节要解决的问题:学会识别"看起来在测、其实没测"的测试。

5. 核心概念 ​

5.1 判断标准:一个问题 ​

写完一个测试后,问自己:

如果我把被测逻辑重新实现一遍(行为完全不变,只是换个写法),这个测试还会通过吗?

答案含义
会通过测试绑定的是行为 ✓ 好测试
会失败测试绑定的是实现细节 ✗ 脆弱的测试

举例:

java
// 断言"结果正确"——重写实现照样通过 ✓
assertThat(service.rename("u1", "新名")).isEqualTo("新名");

// 断言"必须调用了 findById"——重写实现(如改成从缓存取)就红了 ✗
verify(repository).findById("u1");

注意:verify(repository).save(...) 是必要的("数据必须被持久化"是业务要求);而 verify(repository).findById("u1") 通常是多余的("怎么拿到数据"是实现选择)。

这条标准是本节的核心工具,后面的五种反模式都是它的具体表现。

5.2 反模式一:mock 值对象或纯计算类 ​

java
// ✗ 反例
OrderCalculator calculator = mock(OrderCalculator.class);
when(calculator.payableInCents(5_000L, 2)).thenReturn(9_000L);
assertThat(calculator.payableInCents(5_000L, 2)).isEqualTo(9_000L);
java
// ✓ 正例
OrderCalculator calculator = new OrderCalculator();
assertThat(calculator.payableInCents(5_000L, 2)).isEqualTo(9_000L);

判断依据:这个类有没有需要被隔离的外部依赖? 没有(纯计算、值对象),就用真实实例。

值对象同理:User、Money、DateRange 这类只有数据没有行为的类型,构造一个真实实例的成本是零,没有理由 mock 它。

5.3 反模式二:验证每一步调用 ​

java
// ✗ 反例:把调用序列写成断言
verify(repository).findById("u1");
verify(repository).save(any(User.class));
verify(notifier).send("u1", "您的名字已更改为 新名");
java
// ✓ 正例:只验证"可观察的结果"
assertThat(result).isEqualTo("新名");
verify(repository).save(new User("u1", "新名", "u1@test.com"));
verify(notifier).send("u1", "您的名字已更改为 新名");

差别在第一条 verify(repository).findById("u1"):

它锁定了"必须先查一次仓储"这个实现细节。一旦有人加上缓存("先查缓存,未命中再查库"),这个测试就会红——但它没有指出任何行为退化。

怎么区分"必要"与"过度":

断言类型该不该有
save(...) 被调用,参数正确业务要求(数据要落库)✓ 该有
send(...) 被调用,消息正确业务要求(用户要收到通知)✓ 该有
findById(...) 被调用实现选择(怎么读数据)✗ 通常多余
findById 被调用了恰好一次实现选择(是否缓存)✗ 多余
调用顺序是 findById → save → send部分是实现细节视情况

最后一行要说明:"先保存再通知"可能确实是业务要求(避免通知了但没存上);而"先查再存"通常不是。判断标准是"顺序错了会不会造成业务问题"。

5.4 反模式三:Mock 爆炸(准备比断言长) ​

java
// ✗ 反例:5 行准备,2 行断言
UserRepository mockRepository = mock(UserRepository.class);
Notifier mockNotifier = mock(Notifier.class);
when(mockRepository.findById("u1")).thenReturn(Optional.of(user));
when(mockNotifier.isEnabled("u1")).thenReturn(true);
when(mockRepository.save(any())).thenReturn(user);

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

verify(mockRepository).save(any(User.class));
verify(mockNotifier).send(any(), any());

信号很直观:准备代码比断言长得多。这时测试的读者要花更多精力理解"这些桩是干什么的",而不是"被测行为对不对"。

修复方向:

  1. 把重复的桩抽到 @BeforeEach 或私有方法(如果多个测试共用);
  2. 审视依赖是不是太多——如果被测类需要 5 个协作者,那可能它承担了太多职责(这是设计问题,不是测试问题);
  3. 对无状态依赖用 Fake,减少桩的数量。

5.5 反模式四:用 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);
});

问题:这是被测系统逻辑的一份副本。真实规则改了(比如会员改成减 800),桩不会跟着变——测试仍然绿,验证的却是一份过期的规则。

修复:用固定的、测试里显式写出的期望值:

java
when(pricingClient.quote(any())).thenReturn(new Quote(9_000L));

这样"9000"是一个明确的输入条件,而不是被测试代码"算出来"的。

5.6 反模式五:为了 mock 而制造依赖 ​

现象:为了让某个逻辑"可以被 mock",在生产代码里注入了本不需要的依赖。

java
// ✗ 反例:为了让测试能 mock "当前时间",注入了 Clock——这没错,
// 但如果为了 mock 一个"空实现"而注入了一个接口,就是负担
public class PriceFormatter {
    private final CurrencyProvider currencyProvider;      // 永远只返回 "CNY"

    public String format(long cents) {
        return cents / 100.0 + " " + currencyProvider.symbol();
    }
}

判断标准:这个依赖在真实运行时有多个实现吗? 没有的话,它就是为了测试而存在的额外间接层。

但要小心反向的误判:Clock、IdGenerator、Random 这类"环境依赖"确实应该注入——因为它们的值在测试里不可控。这不是为了 mock,而是为了让不确定性可控(T1-006 可测试性与 Arrange-Act-Assert 会展开讲)。

区分两者的简单问法:

问题该注入不该注入
值在测试里可控吗?不可控(时间、随机、id)可控(固定值、纯函数)
真实运行时有多种实现吗?有(不同环境、不同实现)只有一种

5.7 五种反模式的速查 ​

反模式典型症状修复
mock 值对象/纯计算类断言在"复述打桩值"用真实实例
验证每一步调用verify 了查询、次数、顺序只 verify 业务要求
Mock 爆炸准备比断言长抽公共桩 / 审视职责
thenAnswer 重写逻辑桩里有 if-else 与业务规则用固定期望值或 Fake
为 mock 造依赖只有一个实现的接口直接调用,去掉间接层

6. 递进示例 ​

6.1 最小示例:真实实例 vs Mock ​

见 5.2 节。这是最容易被忽略、后果最严重的一种——因为它不报错、还贡献覆盖率。

6.2 业务示例:从"验证调用"到"验证结果" ​

java
// 改造前:3 条 verify,锁死了实现步骤
verify(repository).findById("u1");
verify(repository).save(any());
verify(notifier).send(any(), any());

// 改造后:1 条结果断言 + 2 条业务要求
assertThat(result).isEqualTo("新名");
verify(repository).save(new User("u1", "新名", "u1@test.com"));
verify(notifier).send("u1", "您的名字已更改为 新名");

注意改造后仍然保留了 2 条 verify——"不该 Mock"不等于"不该验证交互"。判断标准始终是"这是不是业务要求"。

6.3 反例:把测试写成"实现说明书" ​

java
verify(repository, times(1)).findById("u1");
verify(repository, never()).findById("u2");
verify(notifier, times(1)).send(anyString(), anyString());
verifyNoMoreInteractions(repository, notifier);

这四条断言组合起来,等于把"当前实现"逐字抄了一遍。任何重构都会让它变红,而它唯一能抓到的 bug 是"实现被改了"——这不是 bug,这是重构。

7. 完整可运行代码 ​

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

注意:本节的"反例"测试都是通过的——这正是它危险的地方。看代码时请关注注释里指出的"它验证了什么"。

java
@ExtendWith(MockitoExtension.class)
@DisplayName("过度 Mock 的反模式与修复")
class OverMockingTest {
    @Mock
    private UserRepository repository;

    @Mock
    private Notifier notifier;

    @Test
    @DisplayName("反例:mock 纯计算类——断言只是在复述打桩时写下的值")
    void anti_pattern_mocking_a_pure_calculation() {
        OrderCalculator calculator = mock(OrderCalculator.class);
        when(calculator.payableInCents(5_000L, 2)).thenReturn(9_000L);

        assertThat(calculator.payableInCents(5_000L, 2)).isEqualTo(9_000L);
        // 这个测试是绿的,但真实的 OrderCalculator 一行都没有执行:
        // 打桩写下 9000,断言又验证 9000——保护力为零。
        // 值对象同理:它只有数据没有行为,mock 它等于伪造数据。
    }

    @Test
    @DisplayName("正例:值对象用真实实例,断言的是真实行为")
    void prefer_real_value_objects() {
        User user = new User("u1", "张三", "zhangsan@test.com");

        assertThat(user.name()).isEqualTo("张三");
        assertThat(user.rename("李四").name()).isEqualTo("李四");
        // rename 是真实逻辑:新对象换了名字,原对象的邮箱保持不变
        assertThat(user.rename("李四").email()).isEqualTo("zhangsan@test.com");
    }

    @Test
    @DisplayName("反例:验证了每一步调用,把实现细节锁死")
    void anti_pattern_verifying_every_call() {
        when(repository.findById("u1"))
                .thenReturn(Optional.of(new User("u1", "旧名", "u1@test.com")));
        UserService service = new UserService(repository, notifier);

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

        verify(repository).findById("u1");
        verify(repository).save(any(User.class));
        verify(notifier).send("u1", "您的名字已更改为 新名");
        // verify(repository).findById("u1") 这类断言锁死了"必须先查一次库"
        // 一旦实现改成"先查缓存",测试就会红——但它没有指出任何行为退化
    }

    @Test
    @DisplayName("正例:只验证可观察的结果")
    void prefer_verifying_observable_outcomes() {
        when(repository.findById("u1"))
                .thenReturn(Optional.of(new User("u1", "旧名", "u1@test.com")));
        UserService service = new UserService(repository, notifier);

        String result = service.rename("u1", "新名");

        assertThat(result).isEqualTo("新名");
        verify(repository).save(new User("u1", "新名", "u1@test.com"));
        verify(notifier).send("u1", "您的名字已更改为 新名");
    }

    @Test
    @DisplayName("反例:为了 mock 而制造依赖——测试比生产代码还复杂")
    void anti_pattern_mock_explosion() {
        UserRepository mockRepository = mock(UserRepository.class);
        Notifier mockNotifier = mock(Notifier.class);
        when(mockRepository.findById("u1"))
                .thenReturn(Optional.of(new User("u1", "旧名", "u1@test.com")));

        UserService service = new UserService(mockRepository, mockNotifier);
        service.rename("u1", "新名");

        verify(mockRepository).save(any(User.class));
        verify(mockNotifier).send(any(), any());
        // 5 行准备,2 行断言——当准备比断言长得多时,测试正在变脆
    }

    @Test
    @DisplayName("正例:只 mock 真正的边界,其余用真实对象")
    void prefer_mocking_only_the_boundary() {
        // 只有 repository(外部存储)与 notifier(外部系统)是边界,需要替身;
        // 领域逻辑(UserService、User)用真实实现
        when(repository.findById("u1"))
                .thenReturn(Optional.of(new User("u1", "旧名", "u1@test.com")));
        UserService service = new UserService(repository, notifier);

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

        verify(repository).save(new User("u1", "新名", "u1@test.com"));
        verify(notifier).send("u1", "您的名字已更改为 新名");
    }
}

运行命令:

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

预期输出:

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

注意 6 个测试全绿——反例和正例一样"通过"。测试的世界里,"绿"从来不等于"对"。

三组对比的核心差别:

对比反例的问题正例做对了什么
纯计算 vs 真实实例真实逻辑没跑用真实对象测真实行为
验证每步调用 vs 验证结果锁死实现细节只 verify 业务要求
Mock 爆炸 vs 只 mock 边界准备淹没断言边界才替身,领域逻辑用真货

8. Debug 观察点 ​

  1. 在 anti_pattern_mocking_a_pure_calculation 的 when(...) 处断点,观察 OrderCalculator 类从未被加载执行——只有 mock 的代理对象在工作;
  2. 对比在 prefer_real_value_objects 的 user.rename("李四") 处断点:这里是真实的 record 方法在执行;
  3. 把 anti_pattern_verifying_every_call 的 verify(repository).findById("u1") 注释掉,然后在 UserService 里把"查库"改成"查两次库"(多加一行 repository.findById(userId)),重跑:
    • 反例版本(带 verify):会失败(因为多调用了一次)
    • 正例版本(不带):仍然通过 ✓ 这个实验能让你直观看到两类断言的区别。

9. 常见错误 ​

错误 1:把"覆盖率"当成"测试有效性" ​

text
JaCoCo: OrderCalculator 覆盖 100%

但如果测试里 mock(OrderCalculator.class),覆盖率统计的是"字节码被执行过",而 mock 代理不会执行真实字节码——那 100% 是从别的测试来的。

提醒:覆盖率是"有没有测"的粗略指标,不是"测得好不好"的指标(T1-013 分支覆盖率与覆盖率误区 会展开)。

错误 2:认为"verify 越多越严谨" ​

恰恰相反:每多一条 verify,就多锁死一个实现细节。严谨应该体现在"业务规则的覆盖"上,而不是"调用次数的精确"上。

错误 3:为了消除测试里的 new 而引入工厂 ​

java
// 过度设计:为了"可 mock"而抽象
interface UserFactory {
    User create(String id, String name, String email);
}

new User(...) 没有任何需要隔离的东西。测试里出现 new 是正常的——它通常意味着"这里用了真实的领域对象",是好事。

错误 4:把"测试能跑"当成"设计合理" ​

如果一个类必须 mock 五个依赖才能测试,那问题不在测试技巧,在这个类的职责过多。

这是本节最重要的"溢出价值":测试的难写程度是设计的体检报告。

测试里的症状对应的设计问题
需要 mock 5 个以上依赖职责过多,该拆分
需要 thenAnswer 模拟复杂状态状态应该由 Fake 或领域对象承载
断言要穿透三层对象得墨忒耳定律违反(链式调用过深)
每个测试都要重新搭一遍缺少合适的构造方式(建造者/工厂)

错误 5:走向另一个极端——完全不 Mock ​

java
// 极端:为了"真实",测试直接连数据库
class UserServiceTest {
    private final UserService service =
            new UserService(new JdbcUserRepository("jdbc:mysql://localhost/test"), new SmtpNotifier());
}

问题:测试依赖外部环境(数据库、邮件服务器),变慢、变脆、CI 上跑不了。

正确的边界:只 mock 边界(进程外的东西:数据库、网络、文件、时间),进程内的领域逻辑用真实实现。

10. 动手实验 ​

目标:亲手验证"测试绑定了实现细节会怎样"。

步骤:

  1. 运行第 7 节的命令,确认 6 个测试全绿;
  2. 重构实验:把 UserService.rename 的实现改一下(行为不变)——比如先判断 findById 的结果再校验名字;
    • 对 anti_pattern_verifying_every_call 与 prefer_verifying_observable_outcomes 分别重跑;
    • 记录哪个变红了,以及"变红是否能说明有 bug";
  3. 假绿实验:在 anti_pattern_mocking_a_pure_calculation 之外,再写一个真实的 OrderCalculator 测试,把 payableInCents 的实现改成 return 0;;
    • 观察哪个测试能抓到(真实测试变红、mock 测试仍然绿);
  4. 数一数 UserService 的所有测试里,verify 语句有几条是"查询类"、几条是"写入/通知类",判断哪些可以删掉;
  5. 给自己之前写的某个测试做"实现重写测试":把被测方法换个写法但保持行为不变,跑一遍——红的那些断言就是过度约束。

验收方式:能说出第 2 步中"变红的那条断言"为什么不能算 bug 保护。

扩展任务:找一个"必须 mock 4 个以上依赖"的测试(可以自己造一个),尝试只保留 1-2 个真正必要的替身,用 Fake 或真实对象替换其余部分——记录改造后测试的行数与可读性变化。

11. 练习题 ​

基础题

  1. 判断:测试全绿说明测试有效。(对 / 错)
  2. 填空:判断测试是否绑定实现细节的方法是问"______"。
  3. 简答:为什么 verify(repository).save(...) 通常是必要的,而 verify(repository).findById(...) 通常是多余的?

代码阅读题

java
@Test
void calculate_total() {
    Cart cart = mock(Cart.class);
    when(cart.itemCount()).thenReturn(3);
    when(cart.totalInCents()).thenReturn(30_000L);

    assertThat(cart.totalInCents()).isEqualTo(30_000L);
}
  1. 这个测试的问题是什么?它会通过吗?
  2. 如果 Cart 是个有真实逻辑的类,正确的写法应该是什么?

编程题

  1. 找出并修复一组过度 Mock 的测试:
    • 下面这段测试有至少三处反模式,请逐一指出并改写:
java
@Test
void place_order() {
    StockRepository stock = mock(StockRepository.class);
    PaymentGateway gateway = mock(PaymentGateway.class);
    when(stock.stockOf("A")).thenReturn(10);
    when(stock.stockOf("A")).thenReturn(10);           // 扣减后仍是 10?
    Payment payment = mock(Payment.class);
    when(payment.quantity()).thenReturn(3);

    new OrderService(stock, gateway).place("A", 3);

    verify(stock).stockOf("A");
    verify(stock).decrease("A", 3);
    verify(gateway).charge(any());
    verifyNoMoreInteractions(stock);
}

综合任务

  1. 对一个"用户注册"流程做测试审查(没有现成代码,自行设计被测类 RegisterService):
    • 依赖:UserRepository(查重与保存)、EmailSender(发欢迎邮件)、IdGenerator(生成 id);
    • 写一版"过度 Mock"的实现(至少包含本节三种反模式);
    • 再写一版"合理替身"的实现(说明每处为什么这样选);
    • 用"实现重写测试"验证两版的差异:把 RegisterService 的实现换个写法(行为不变),看哪一版会变红;
    • 说明:如果你要把这套测试交给同事维护,你会怎么解释"哪些 verify 可以删"?
分级提示

提示 1(方向):第 4 题的关键是"Cart 的真实逻辑完全没有执行"。

提示 2(关键点):第 6 题的 when(payment.quantity()) 是"mock 值对象"的典型——Payment 应该直接 new。

提示 3(接近答案):第 7 题的"合理版本"里:UserRepository 用 Fake(有状态:查重后保存)、EmailSender 用 Mock(验证交互)、IdGenerator 用 Stub(() -> "固定id" 或 Fake 递增)。

12. 参考答案 ​

基础题
  1. 错。"绿"只说明"断言都满足了",不说明"断言验证了有意义的东西"。本节六个测试全绿,但其中三个是反例——它们删掉也不会降低任何保护力。
  2. "如果我把被测逻辑重新实现一遍(行为不变),这个测试还会通过吗?"——会通过说明它绑定的是行为,会失败说明它绑定了实现细节。
  3. 因为 save 对应的是业务要求("改名必须被持久化"),而 findById 对应的是实现选择("怎么读到数据")。前者一旦不满足,用户会发现问题;后者改成缓存、批量查询、走另一个数据源都不影响用户看到的结果。测试应该守住前者,而不是限制后者。
代码阅读题(第 4、5 题)

第 4 题的问题:

mock(Cart.class) 让真实的 Cart 逻辑完全没有执行。这个测试验证的只是"mock 按打桩返回值"——它是一条自证预言。

会通过 ✓(这正是它危险的地方)

第 5 题的正确写法:

java
@Test
void calculate_total() {
    Cart cart = new Cart();
    cart.add("键盘", 19_900L, 1);
    cart.add("鼠标", 9_900L, 1);
    cart.add("鼠标垫", 200L, 1);

    assertThat(cart.itemCount()).isEqualTo(3);
    assertThat(cart.totalInCents()).isEqualTo(30_000L);
}

为什么更好:真实的 Cart 逻辑(加商品、累加金额、计算数量)真的被验证了。如果 add 或 totalInCents 有 bug,这个测试会抓到;而 mock 版本不会。

顺带一个收益:测试变更短了——不用写 when(...),只需要构造真实场景。

编程题(第 6 题)

三处反模式:

位置反模式问题
when(stock.stockOf("A")).thenReturn(10) 写了两遍用 Mock 模拟状态第二行会覆盖第一行,且扣减后库存没有真实变化
Payment payment = mock(Payment.class)mock 值对象Payment 是 record,直接 new 就行
verify(stock).stockOf("A")验证实现细节"查库存"是实现选择,不是业务要求
verifyNoMoreInteractions(stock)过度约束任何新增的合理调用都会让它红

改写后的版本:

java
package cn.kb.learn.testing;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.util.HashMap;
import java.util.Map;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;

@DisplayName("下单:合理替身版本")
class PlaceOrderCleanTest {
    interface StockRepository {
        int stockOf(String sku);

        void decrease(String sku, int quantity);

        void increase(String sku, int quantity);
    }

    record Payment(String sku, int quantity) {
    }

    interface PaymentGateway {
        void charge(Payment payment);
    }

    /** Fake:库存有状态,让它真实流转 */
    static class FakeStockRepository implements StockRepository {
        private final Map<String, Integer> stocks = new HashMap<>();

        void put(String sku, int quantity) {
            stocks.put(sku, quantity);
        }

        @Override
        public int stockOf(String sku) {
            return stocks.getOrDefault(sku, 0);
        }

        @Override
        public void decrease(String sku, int quantity) {
            stocks.merge(sku, -quantity, Integer::sum);
        }

        @Override
        public void increase(String sku, int quantity) {
            stocks.merge(sku, quantity, Integer::sum);
        }
    }

    interface OrderService {
        void place(String sku, int quantity);
    }

    private FakeStockRepository stockRepository;
    private PaymentGateway paymentGateway;
    private OrderService service;

    @BeforeEach
    void setUp() {
        stockRepository = new FakeStockRepository();
        paymentGateway = mock(PaymentGateway.class);
        service = new PlaceOrderService(stockRepository, paymentGateway);
    }

    @Test
    @DisplayName("下单成功:库存减少、扣款参数正确")
    void decreases_stock_and_charges() {
        stockRepository.put("A", 10);

        service.place("A", 3);

        assertThat(stockRepository.stockOf("A")).isEqualTo(7);       // 状态断言
        verify(paymentGateway).charge(new Payment("A", 3));          // 业务要求(值对象用真实实例)
    }

    @Test
    @DisplayName("库存不足:不改库存")
    void rejects_when_not_enough_stock() {
        stockRepository.put("A", 2);

        assertThatThrownBy(() -> service.place("A", 5))
                .isInstanceOf(IllegalStateException.class);

        assertThat(stockRepository.stockOf("A")).isEqualTo(2);
    }
}

改动清单:

原写法新写法理由
mock(StockRepository) + 两次打桩Fake库存是有状态依赖,状态断言比交互断言更真实
mock(Payment) + 打桩new Payment("A", 3)值对象直接构造
verify(stock).stockOf("A")删除实现细节
verifyNoMoreInteractions(stock)删除过度约束
verify(gateway).charge(any())verify(gateway).charge(new Payment("A", 3))从"被调用了"提升到"参数正确"

注意最后一行:any() 与具体参数的差别很大——前者只能发现"没调用",后者还能发现"调用了但参数错了"。

综合任务(第 7 题)

过度 Mock 版本(三处反模式都体现):

java
@Test
void register_over_mocked() {
    // 反模式 1:mock 值对象(User)
    User mockUser = mock(User.class);
    when(mockUser.id()).thenReturn("u1");
    // 反模式 2:thenAnswer 重写业务逻辑(自己算校验和)
    when(repository.exists(anyString())).thenAnswer(invocation -> false);
    // 反模式 3:验证每一步调用
    when(idGenerator.next()).thenReturn("u1");
    when(repository.save(any())).thenReturn(mockUser);

    service.register("zhangsan@test.com", "密码");

    verify(repository).exists("zhangsan@test.com");
    verify(idGenerator).next();
    verify(repository).save(any(User.class));
    verify(emailSender).sendWelcome("zhangsan@test.com");
    verifyNoMoreInteractions(repository, idGenerator, emailSender);
}

问题清单:

反模式具体表现后果
mock 值对象mock(User.class)真实 User 逻辑(如邮箱规范化)没被执行
thenAnswer 重写逻辑用 Lambda 返回 false看似"模拟查重通过",实际是把结论写死
验证每一步调用4 条 verify + verifyNoMoreInteractions任何实现调整都会红

合理替身版本:

java
@DisplayName("注册流程:合理替身")
class RegisterServiceCleanTest {
    interface UserRepository {
        boolean exists(String email);

        void save(User user);
    }

    interface EmailSender {
        void sendWelcome(String email);
    }

    interface IdGenerator {
        String next();
    }

    /** Fake:有状态(记住已注册的邮箱),支持真实的查重语义 */
    static class FakeUserRepository implements UserRepository {
        private final Set<String> emails = new HashSet<>();

        @Override
        public boolean exists(String email) {
            return emails.contains(email);
        }

        @Override
        public void save(User user) {
            emails.add(user.email());
        }
    }

    private FakeUserRepository repository;
    private EmailSender emailSender;
    private RegisterService service;

    @BeforeEach
    void setUp() {
        repository = new FakeUserRepository();
        emailSender = mock(EmailSender.class);                     // Mock:验证"欢迎邮件发出去了"
        IdGenerator idGenerator = () -> "fixed-id";                // Stub:返回值可控
        service = new RegisterService(repository, emailSender, idGenerator);
    }

    @Test
    @DisplayName("注册成功:邮箱已存、欢迎邮件发出")
    void registers_new_user() {
        service.register("zhangsan@test.com", "密码");

        assertThat(repository.exists("zhangsan@test.com")).isTrue();          // 状态断言
        verify(emailSender).sendWelcome("zhangsan@test.com");                 // 业务要求
    }

    @Test
    @DisplayName("邮箱重复:抛异常且不发邮件")
    void rejects_duplicate_email() {
        repository.save(new User("u1", "zhangsan@test.com", "密码"));

        assertThatThrownBy(() -> service.register("zhangsan@test.com", "密码"))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("已注册");

        verify(emailSender, never()).sendWelcome(anyString());
    }
}

替身选择说明:

依赖选择理由
UserRepositoryFake有状态:注册后 exists 必须返回 true。"邮箱重复"这个核心场景只有 Fake 能自然表达
EmailSenderMock无状态、无法直接观察(没法检查收件箱),只能验证交互
IdGeneratorStub(Lambda)单方法接口、无状态、返回值固定

"实现重写测试"的结果:

把 RegisterService.register 的实现换个写法(如把"先查重"改成"先规范化邮箱再查重"):

  • 过度 Mock 版本:verify(repository).exists("zhangsan@test.com") 可能因为参数变了(规范化后的邮箱)而变红——但业务行为没变;
  • 合理替身版本:所有断言仍然通过 ✓(业务行为一致)。

怎么向同事解释"哪些 verify 可以删":

一句话标准——问"这条断言失败时,是不符合业务要求,还是不符合当前实现?"

断言失败意味着结论
verify(emailSender).sendWelcome(email)用户收不到欢迎邮件保留 ✓
verify(repository).exists(email)只是实现改了读写顺序删除 ✗
verify(idGenerator).next()只是 id 来源变了删除 ✗

最后的建议:写测试时先写"结果断言",写完后再问"还需要验证哪些交互"。顺序反过来(先写一堆 verify)最容易滑向过度 Mock。

13. 掌握标准 ​

  • [ ] 能用"实现重写测试"识别绑定了实现细节的断言;
  • [ ] 能说出五种过度 Mock 反模式及修复方向;
  • [ ] 能区分"业务要求"(该 verify)与"实现选择"(不该 verify);
  • [ ] 知道 verify 越多不等于越严谨,反而更容易变脆;
  • [ ] 遇到"必须 mock 很多依赖"时,会去检查设计问题而不是只改测试;
  • [ ] 记住:测试的世界里,"绿"从来不等于"对"。

14. 扩展资源 ​

15. 下一步学习 ​

Mockito 三节到此完成。接下来:

后续知识

暂无

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

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