Skip to content

测试替身全景:Stub、Spy、Fake 与 Mock 边界 ​

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

1. 本节定位 ​

《Mockito:Mock、Stub 与 Verify》 认识了 mock() / when() / verify()。但"测试替身"不止 mock 一种——用错类型,测试的可信度会打折。

本节把五种替身(Dummy、Stub、Spy、Fake、Mock)摆在一起对比,重点是选择依据:同一个依赖,什么时候该用 Fake、什么时候该用 Mock。

2. 学习目标 ​

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

  • 说出五种测试替身的定义与典型用途;
  • 手写 Dummy、Stub、Spy、Fake 四种替身(不依赖 Mockito);
  • 说清 Mockito 的 spy() 与手写 Spy、mock() 的区别;
  • 判断一个依赖该用 Fake 还是 Mock,并说出依据;
  • 说出 Fake 相比 Mock 的独特价值。

3. 前置知识检查 ​

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

很多人的测试工具箱里只有一把锤子:mock()。于是出现这些写法:

java
// 用 mock 模拟"查询返回空"
when(repository.findById("u404")).thenReturn(Optional.empty());

// 用 mock 模拟"库存为 0"
when(stockRepository.stockOf("A")).thenReturn(0);

// 用 mock 模拟"库存流转"——这里开始别扭了
when(stockRepository.stockOf("A")).thenReturn(10);
// ... 被测代码调用 decrease 之后 ...
// 需要再打一次桩才能让后续的 stockOf 返回 9
when(stockRepository.stockOf("A")).thenReturn(9);

最后那种写法是信号:被测代码维护了状态(扣减库存),而 mock 没有状态——你不得不手工同步"世界状态"。改成 Fake(一个内存实现)就自然多了:

java
FakeStockRepository fake = new FakeStockRepository();
fake.put("A", 10);
// decrease 一次,再查就是 9——Fake 自己维护状态,不需要重新打桩

这就是本节要解决的问题:知道每种替身擅长什么,才能选对工具。

5. 核心概念 ​

5.1 五种替身对照表 ​

替身一句话定义是否被断言手写难度典型场景
Dummy只为填参数,从不被使用✗极简(空实现)被测路径走不到它
Stub返回预先准备的值间接(通过被测结果)简单依赖提供固定数据
Spy记录调用,事后断言✓(断言调用)简单验证"通知发出去了"
Fake有真实逻辑的简化实现✓(断言状态)中等内存仓储、内存队列
Mock预设期望 + 自动记录调用✓(断言交互)用 Mockito验证与外部系统的交互

5.2 Dummy:只为填参数 ​

java
AuditLog dummy = (sku, price) -> { };                    // 空实现,什么都不做
PricingService service = new PricingService(stubRepo, dummy);

使用条件:被测路径不会调用它。本节代码里测的是"非法折扣参数校验",校验在查价与记录日志之前就抛异常了——所以 dummy 从头到尾没被调用。

如果它被调用了呢? 那就不是 Dummy,而是"一个空实现的 Stub"——没被断言、也没提供数据,等于白写。这种情况下要么补断言,要么承认它是多余的。

5.3 Stub:提供固定答案 ​

java
PriceRepository stub = sku -> 10_000L;                   // 任何 SKU 都返回 10000

特点:只关心"喂给被测代码什么",不关心"被测代码拿它做了什么"。

用 Mockito 的等价写法:when(mock.priceOf(anyString())).thenReturn(10_000L)。

Stub 就该这么简单——如果打桩超过两三行,通常是设计出了问题(见 T1-012)。

5.4 Spy:先记录,后断言 ​

手写版:

java
List<String> recorded = new ArrayList<>();
AuditLog spy = (sku, price) -> recorded.add(sku + ":" + price);

service.applyDiscount("A", 10);
assertThat(recorded).containsExactly("A:9000");          // 事后断言

Mockito 版:

java
PriceRepository spyRepository = spy(realRepository);     // 包装真实对象
service.applyDiscount("A", 10);
verify(spyRepository).priceOf("A");                      // 自动记录,自动断言

两种 Spy 的关键区别:

手写 SpyMockito 的 spy()
行为自己决定(通常只记录)默认执行真实逻辑,可选择性打桩覆盖
适用只需要"记一笔"想在真实行为上验证或局部替换

spy() 的定位要清楚:它是"真实对象 + 可验证的调用记录"。所以它适合"我想用真实实现,但还想验证某个方法被调过"——如果整个对象都需要替换,那就该用 mock()。

5.5 Fake:有真实逻辑的简化实现 ​

java
static class InMemoryPriceRepository implements PriceRepository {
    private final Map<String, Long> prices = new HashMap<>();

    void put(String sku, long priceInCents) {
        prices.put(sku, priceInCents);
    }

    @Override
    public long priceOf(String sku) {
        return prices.getOrDefault(sku, 0L);                 // 真实逻辑:不存在则返回 0
    }
}

Fake 与 Mock 的本质区别:

MockFake
状态无状态(除非手工打桩模拟)自己维护状态
行为真实性由打桩决定有真实逻辑
断言方式验证交互(verify)验证最终状态
复用性每个测试各打各的桩可跨测试复用(甚至能单测它自己)
风险桩写错 = 假的也许Fake 自己可能有 bug

什么时候该用 Fake:

  • 依赖有状态(库存、余额、计数器)——被测代码会读它、改它、再读它;
  • 依赖的行为会被多个测试反复使用——一次写好,各处复用;
  • 你想让测试更接近"真实跑一遍"的感觉。

这就是很多项目里 InMemoryXxxRepository、FakeClock、FakeMessageQueue 存在的原因。

Fake 的代价:它自己也需要维护和测试。所以判断标准是"这个 Fake 会被多少个测试用到"——只用一次,不如直接打桩。

5.6 Mock:验证交互 ​

java
verify(mockRepository).priceOf("A");
verify(mockAuditLog).record("A", 9_000L);
verifyNoMoreInteractions(mockRepository);

Mock 最擅长的场景:验证"对一个无法直接观察结果的依赖做了什么"。

比如"发送邮件"——你没法在单元测试里检查收件箱,只能验证"send 被调用了,参数是什么"。

列表里的 verifyNoMoreInteractions 值得单说:它断言"除了上面验证过的,这个 mock 没有再被调用过"。它比逐条 verify 更严格,能抓住"多调用了一次"这种问题。

但要小心:verifyNoMoreInteractions 会让测试对"实现多调了一次查询"这类无害变化也敏感——只在"多余的调用确实是问题"时使用(如"不该重复扣款")。

5.7 选择依据(本节重点) ​

text
需要验证"与外部系统的交互"      →  Mock
依赖有状态、被测代码会读改写      →  Fake
只想喂数据、不关心交互            →  Stub
想用真实实现、但记录调用          →  Spy(Mockito 的 spy)
只为了填参数、不会被用到          →  Dummy

再加一条实践判断:

信号说明
打桩超过 3 行考虑换成 Fake
需要在多个测试里重复同一套桩抽成 Fake 或公共的 fixture 方法
为了模拟状态变化而"再打一次桩"强烈建议换 Fake
只有一个方法、无状态、返回值固定Stub 或直接 Lambda

6. 递进示例 ​

6.1 最小示例:Stub 用 Lambda ​

java
PriceRepository stub = sku -> 10_000L;

单方法接口的 Stub 不需要 Mockito——Lambda 更短、更直白、没有隐式行为(Mockito 的 mock 默认返回 null/0,容易踩坑)。

6.2 业务示例:Fake 处理状态 ​

java
InMemoryPriceRepository fake = new InMemoryPriceRepository();
fake.put("A", 10_000L);
fake.put("B", 20_000L);

Fake 的数据在测试里一眼可见——不用在 when(...) 里拼装。

6.3 反例:用 Mock 模拟状态变化 ​

java
when(stockRepository.stockOf("A")).thenReturn(10);       // 第一次查:10
// ... 被测代码 decrease("A", 3) ...
when(stockRepository.stockOf("A")).thenReturn(7);        // 手工同步到 7

问题:这种桩里的数字是"你希望的流程",不是"代码真实执行后的结果"。一旦被测代码少扣一次或多扣一次,这个测试仍然会通过——因为它验证的是你手写的剧本,而不是系统的行为。

修复:换成 Fake,让状态真实地流转。

7. 完整可运行代码 ​

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

java
@DisplayName("五种测试替身")
class TestDoubleTest {
    interface PriceRepository {
        long priceOf(String sku);
    }

    interface AuditLog {
        void record(String sku, long priceInCents);
    }

    /** Fake:有真实逻辑的简化实现(这里用 Map 代替数据库) */
    static class InMemoryPriceRepository implements PriceRepository {
        private final Map<String, Long> prices = new HashMap<>();

        void put(String sku, long priceInCents) {
            prices.put(sku, priceInCents);
        }

        @Override
        public long priceOf(String sku) {
            return prices.getOrDefault(sku, 0L);
        }
    }

    static class PricingService {
        private final PriceRepository repository;
        private final AuditLog auditLog;

        PricingService(PriceRepository repository, AuditLog auditLog) {
            this.repository = repository;
            this.auditLog = auditLog;
        }

        long applyDiscount(String sku, int percent) {
            if (percent < 0 || percent > 100) {
                throw new IllegalArgumentException("折扣必须在 0-100 之间");
            }
            long price = repository.priceOf(sku);
            long payable = price * (100 - percent) / 100;
            auditLog.record(sku, payable);
            return payable;
        }
    }

    /** Dummy:只为满足构造参数,从不会被使用 */
    private static AuditLog dummyAuditLog() {
        return (sku, priceInCents) -> {
        };
    }

    @Test
    @DisplayName("Dummy:只用来填充参数,整个测试里从未被调用")
    void dummy_only_fills_the_parameter() {
        PricingService service = new PricingService(sku -> 10_000L, dummyAuditLog());

        assertThatThrownBy(() -> service.applyDiscount("A", 200))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("折扣必须在 0-100 之间");
        // dummyAuditLog 从头到尾没有被调用:这正是 Dummy 的定义
    }

    @Test
    @DisplayName("Stub:提供预先准备的返回值")
    void stub_returns_canned_values() {
        PriceRepository stub = sku -> 10_000L;

        PricingService service = new PricingService(stub, dummyAuditLog());

        assertThat(service.applyDiscount("ANY-SKU", 20)).isEqualTo(8_000L);
    }

    @Test
    @DisplayName("Spy(手写):记录调用,事后断言")
    void handwritten_spy_records_invocations() {
        List<String> recorded = new ArrayList<>();
        AuditLog spy = (sku, price) -> recorded.add(sku + ":" + price);

        PricingService service = new PricingService(sku -> 10_000L, spy);
        service.applyDiscount("A", 10);

        assertThat(recorded).containsExactly("A:9000");
    }

    @Test
    @DisplayName("Fake:有真实逻辑的简化实现")
    void fake_has_real_behavior() {
        InMemoryPriceRepository fake = new InMemoryPriceRepository();
        fake.put("A", 10_000L);
        fake.put("B", 20_000L);

        PricingService service = new PricingService(fake, dummyAuditLog());

        assertThat(service.applyDiscount("A", 10)).isEqualTo(9_000L);
        assertThat(service.applyDiscount("B", 50)).isEqualTo(10_000L);
        // Fake 的真实行为:不存在的 SKU 返回 0,而不是抛异常
        assertThat(service.applyDiscount("NOT_EXIST", 0)).isZero();
    }

    @Test
    @DisplayName("Mock:验证交互,而不只是返回值")
    void mock_verifies_interactions() {
        PriceRepository mockRepository = mock(PriceRepository.class);
        AuditLog mockAuditLog = mock(AuditLog.class);
        when(mockRepository.priceOf("A")).thenReturn(10_000L);

        PricingService service = new PricingService(mockRepository, mockAuditLog);
        service.applyDiscount("A", 10);

        verify(mockRepository).priceOf("A");
        verify(mockAuditLog).record("A", 9_000L);
        verifyNoMoreInteractions(mockRepository);
    }

    @Test
    @DisplayName("Mockito 的 spy:包装真实对象,默认走真实逻辑")
    void mockito_spy_wraps_a_real_object() {
        InMemoryPriceRepository real = new InMemoryPriceRepository();
        real.put("A", 10_000L);
        PriceRepository spyRepository = spy(real);

        PricingService service = new PricingService(spyRepository, dummyAuditLog());

        assertThat(service.applyDiscount("A", 10)).isEqualTo(9_000L);
        verify(spyRepository).priceOf("A");
    }
}

运行命令:

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

预期输出:

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

六种写法对照同一个被测类——把它们并排放进笔记,比背定义有用得多:

测试方法替身断言的是什么
dummy_only_fills_the_parameterDummy异常类型与消息
stub_returns_canned_valuesStub计算结果
handwritten_spy_records_invocationsSpy记录下来的调用参数
fake_has_real_behaviorFake多个 SKU 的真实计算结果(含"不存在"的边界)
mock_verifies_interactionsMock交互顺序与参数
mockito_spy_wraps_a_real_objectMockito Spy真实结果 + 调用记录

8. Debug 观察点 ​

  1. 在 dummy_only_fills_the_parameter 的 lambda 里断点,重跑——断点永远不会命中,这就是 Dummy 的证明;
  2. 在 fake_has_real_behavior 的 prices.getOrDefault(...) 处断点,观察 Fake 内部那个 Map 的真实内容——这是它"有状态"的证据;
  3. 在 spy(real) 处按 F7 进入,观察 Mockito 如何复制真实对象的字段创建一个代理实例;
  4. 把 mock_verifies_interactions 里的 verifyNoMoreInteractions 注释掉,然后在 PricingService 里多加一行 repository.priceOf(sku)(重复查一次价),重跑——观察哪个版本能抓到这个问题。

9. 常见错误 ​

错误 1:MockitoExtension 找不到(少了依赖) ​

text
找不到符号: 类 MockitoExtension
程序包 org.mockito.junit.jupiter 不存在

原因:mockito-junit-jupiter 是独立于 mockito-core 的 artifact。只加 mockito-core 能用 mock(),但用不了 @Mock / @InjectMocks。

xml
<dependency>
  <groupId>org.mockito</groupId>
  <artifactId>mockito-junit-jupiter</artifactId>
  <version>5.14.2</version>
  <scope>test</scope>
</dependency>

错误 2:新 JDK 下 mock 具体类失败(本项目实测遇到) ​

text
java.lang.IllegalArgumentException: Java 25 (69) is not supported by the current version
of Byte Buddy which officially supports Java 24 (68)
  - update Byte Buddy or set net.bytebuddy.experimental as a VM property

现象很有迷惑性:mock 接口正常,mock/spy 具体类挂掉——因为 mock 具体类需要 Byte Buddy 生成子类,而接口不需要。

原因:Mockito 内部依赖 Byte Buddy,而 Byte Buddy 对新 JDK 的字节码版本有明确的支持上限。JDK 升级快于 Mockito/Byte Buddy 的跟进。

两种修复:

xml
<!-- 方案一(推荐):显式升级 byte-buddy 到支持当前 JDK 的版本 -->
<dependency>
  <groupId>net.bytebuddy</groupId>
  <artifactId>byte-buddy</artifactId>
  <version>1.17.6</version>
  <scope>test</scope>
</dependency>
xml
<!-- 方案二:让 surefire 打开实验开关(治标,不推荐长期使用) -->
<configuration>
  <argLine>-Dnet.bytebuddy.experimental=true</argLine>
</configuration>

这条经验值得记住:当"接口 mock 正常、具体类 mock 报错"时,先怀疑字节码工具与新 JDK 的兼容性,而不是自己的代码。

错误 3:把 Dummy 写成了"没断言的 Stub" ​

java
AuditLog dummy = (sku, price) -> { };
service.applyDiscount("A", 10);
// 什么都没验证:这个测试只证明了"没抛异常"

修复:要么补上断言(此时它就不再是 Dummy 了),要么确认"不抛异常"本身就是被测目标。

错误 4:用 spy() 替代 mock() ​

java
PriceRepository spy = spy(new RealPriceRepository());    // 会真的去查数据库!

问题:spy() 默认执行真实逻辑,如果真实实现有副作用(查库、写文件、发请求),测试会变慢甚至失败。修复:需要完全替换就用 mock();只在"想保留真实行为"时才用 spy()。

错误 5:Fake 没被测试 ​

java
class InMemoryRepository implements Repository {
    // 这里有个 bug:get 时用了 put(key, value) 而不是 return
}

问题:Fake 有逻辑,就可能有 bug。如果 Fake 错了,所有用它的测试都建立在错误的地基上。

修复:给 Fake 写它自己的单元测试(它也是代码)。这是使用 Fake 的隐性成本,也是"Fake 只该给多个测试复用"的原因。

10. 动手实验 ​

目标:为同一个依赖分别实现五种替身,并体会选择依据。

步骤:

  1. 运行第 7 节的命令,核对 6 个测试通过;
  2. 写一个 FakeStockRepository(内存实现 StockRepository:stockOf / decrease / increase),并写一个测试:初始库存 10,扣减 3 后查得 7;
  3. 用 Mockito 的 mock 重写第 2 步的测试,观察需要几次 when(...) 才能模拟出"扣减后的状态"——记录这个数字;
  4. 把第 2、3 步的测试各加一条"扣减 5 次"的用例,对比两者的代码量;
  5. 给第 2 步的 Fake 写它自己的测试(验证 decrease 到负数时的行为由你定义,但必须有测试覆盖)。

验收方式:能说出第 3 步中"需要几次打桩"以及它的含义。

扩展任务:给 PricingService 增加一个"折扣不可超过 50%"的新规则,然后用 Dummy 写一个测试(因为该场景下 AuditLog 不会被调用)——说明为什么这里用 Dummy 比用 Mock 更合适。

11. 练习题 ​

基础题

  1. 判断:Fake 与 Mock 都能维护内部状态。(对 / 错)
  2. 填空:只为填参数、从不会被使用的替身叫 ______;有真实逻辑的简化实现叫 ______。
  3. 简答:spy() 与 mock() 的关键区别是什么?

代码阅读题

java
interface Clock {
    Instant now();
}

// 测试 A
Clock stubClock = () -> Instant.parse("2026-09-21T10:00:00Z");

// 测试 B
class FakeClock implements Clock {
    private Instant current;
    FakeClock(Instant start) { this.current = start; }
    void advance(Duration duration) { current = current.plus(duration); }
    @Override public Instant now() { return current; }
}
  1. 测一个"判断是否超时"的方法时,测试 A 和测试 B 分别适合什么场景?
  2. 如果要测"下单后 30 分钟内未支付则自动取消"(需要时间推进),用哪个更合适?为什么?

编程题

  1. 为一个"消息队列消费者"写五种替身的对比测试:
    • 接口 MessageQueue:void publish(String topic, String payload)、Optional<String> poll(String topic);
    • 被测类 NotificationService:void notifyUser(String userId, String message)——内部调用 queue.publish("notification", ...);
    • 用 Dummy 写一个只测"用户 id 为空时抛异常"的测试;
    • 用 Mock 验证"消息被发送到了正确的主题与内容";
    • 用 Fake(内存队列)写一个测试:发送两条消息后能按顺序读出;
    • 给 Fake 本身写一个测试。

综合任务

  1. 为"库存扣减 + 支付"的下单流程选择替身策略:
    • OrderService.place(sku, quantity):先查库存、扣减、再支付;支付失败要回滚库存(参考 labs 里的 OrderService);
    • 为 StockRepository 与 PaymentGateway 各选择一种替身,并写出理由;
    • 用你选择的方案实现所有测试场景:库存充足、库存不足、支付失败回滚、支付失败后库存恢复;
    • 额外要求:给 StockRepository 换一种替身类型再实现一遍,对比两次的代码量与可读性,最后说明你的选择。
分级提示

提示 1(方向):第 4 题的关键是"测试 A 时间不动、测试 B 时间可推进"。

提示 2(关键点):第 6 题的 Fake 用 List<String> 或 Deque 就能实现队列语义。

提示 3(接近答案):第 7 题的"支付失败回滚库存"场景需要验证状态(库存恢复了没有)而不是验证交互——这直接指向 Fake;而"支付网关"只需验证"扣款参数对不对",Mock 更合适。

12. 参考答案 ​

基础题
  1. 错。只有 Fake 自己维护状态。Mock 是无状态的:连续调用同一个方法会返回同一个打桩值,除非你显式设置连续桩(thenReturn(a).thenReturn(b))——而那是"你手写的剧本",不是状态。
  2. Dummy;Fake。
  3. mock() 完全是假的(所有方法返回默认值/null,除非打桩);spy() 包装一个真实对象,默认执行真实逻辑,只在打了桩的方法上替换行为。所以 spy() 适合"用真实实现 + 记录调用",mock() 适合"完全替换依赖"。
代码阅读题(第 4、5 题)

第 4 题:

  • 测试 A(Stub):适合"时间固定"的场景,比如"判断某个时间点是否在有效期内"——只需要一个确定的 Instant;
  • 测试 B(Fake):适合"时间需要推进"的场景,比如超时、过期、缓存失效。

第 5 题:用 Fake(FakeClock)。

"30 分钟内未支付则取消"这个需求需要时间前进才能测到边界:

想测的场景FakeClock 怎么做
刚下单(0 分钟)不推进,直接查→订单未取消
29 分钟advance(Duration.ofMinutes(29)) → 未取消
30 分钟(边界)advance(Duration.ofMinutes(30)) → 取消
31 分钟advance(Duration.ofMinutes(31)) → 取消

用 Stub 做不到:返回值恒定,时间不会前进,你只能测到"一个时间点"的行为。

用 Thread.sleep(30 分钟) 更不行——这是"测试卡死"的典型来源。

顺带一个提醒:"把 Clock 作为构造参数注入"是让这类测试可行的前提——这也是"可测试性设计"的典型案例(T1-006 可测试性与 Arrange-Act-Assert)。

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

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

import java.util.ArrayDeque;
import java.util.Deque;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

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 NotificationServiceTest {
    interface MessageQueue {
        void publish(String topic, String payload);

        Optional<String> poll(String topic);
    }

    static class NotificationService {
        private final MessageQueue queue;

        NotificationService(MessageQueue queue) {
            this.queue = queue;
        }

        void notifyUser(String userId, String message) {
            if (userId == null || userId.isBlank()) {
                throw new IllegalArgumentException("用户不能为空");
            }
            if (message == null || message.isBlank()) {
                throw new IllegalArgumentException("消息不能为空");
            }
            queue.publish("notification", userId + ":" + message);
        }
    }

    /** Fake:内存队列,按主题分组、先进先出 */
    static class InMemoryMessageQueue implements MessageQueue {
        private final Map<String, Deque<String>> topics = new HashMap<>();

        @Override
        public void publish(String topic, String payload) {
            topics.computeIfAbsent(topic, key -> new ArrayDeque<>()).addLast(payload);
        }

        @Override
        public Optional<String> poll(String topic) {
            Deque<String> queue = topics.get(topic);
            return (queue == null || queue.isEmpty()) ? Optional.empty() : Optional.of(queue.pollFirst());
        }
    }

    @Nested
    @DisplayName("Dummy:消息队列不会被用到")
    class WithDummy {
        @Test
        void rejects_blank_user_before_publishing() {
            MessageQueue dummy = (topic, payload) -> { };

            NotificationService service = new NotificationService(dummy);

            assertThatThrownBy(() -> service.notifyUser("  ", "你好"))
                    .isInstanceOf(IllegalArgumentException.class)
                    .hasMessage("用户不能为空");
        }
    }

    @Nested
    @DisplayName("Mock:验证发送的主题与内容")
    class WithMock {
        @Test
        void publishes_to_notification_topic() {
            MessageQueue queue = mock(MessageQueue.class);
            NotificationService service = new NotificationService(queue);

            service.notifyUser("u1", "订单已发货");

            verify(queue).publish("notification", "u1:订单已发货");
        }
    }

    @Nested
    @DisplayName("Fake:验证消息顺序")
    class WithFake {
        @Test
        void keeps_messages_in_order() {
            InMemoryMessageQueue fake = new InMemoryMessageQueue();
            NotificationService service = new NotificationService(fake);

            service.notifyUser("u1", "第一条");
            service.notifyUser("u2", "第二条");

            assertThat(fake.poll("notification")).contains("u1:第一条");
            assertThat(fake.poll("notification")).contains("u2:第二条");
            assertThat(fake.poll("notification")).isEmpty();
        }

        @Test
        void unknown_topic_returns_empty() {
            InMemoryMessageQueue fake = new InMemoryMessageQueue();
            assertThat(fake.poll("not-exist")).isEmpty();
        }
    }
}

注意 Fake 自己那两个测试:它们不测 NotificationService,只测 Fake。这是 Fake 的必要配套——Fake 也是代码,也需要被验证。

为什么第 3 个测试用 Fake 而不是 Mock:因为要验证的是"顺序"这个跨调用的状态属性。用 Mock 就得写连续桩:

java
when(queue.poll("notification")).thenReturn(Optional.of("u1:第一条"));
when(queue.poll("notification")).thenReturn(Optional.of("u2:第二条"));

这段桩本身没有证明任何东西——它只是把你想要的顺序写了两遍。Fake 让顺序成为"真实的执行结果",这才是有意义的验证。

综合任务(第 7 题)

替身选择与理由:

依赖选择理由
StockRepositoryFake有状态:查→扣→(失败时)加。要验证的是"回滚后库存恢复了没有",这是状态断言;用 Mock 就得手工同步每次打桩的返回值,等于把测试写成剧本
PaymentGatewayMock无状态、只关心"扣款参数是否正确"、且"支付失败"要能一键模拟。这是交互断言的典型场景
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.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;

@DisplayName("下单流程:Fake + Mock 组合")
class PlaceOrderStrategyTest {
    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);
        }
    }

    static class OrderService {
        private final StockRepository stockRepository;
        private final PaymentGateway paymentGateway;

        OrderService(StockRepository stockRepository, PaymentGateway paymentGateway) {
            this.stockRepository = stockRepository;
            this.paymentGateway = paymentGateway;
        }

        void place(String sku, int quantity) {
            int stock = stockRepository.stockOf(sku);
            if (stock < quantity) {
                throw new IllegalStateException("库存不足:" + stock + " < " + quantity);
            }
            stockRepository.decrease(sku, quantity);
            try {
                paymentGateway.charge(new Payment(sku, quantity));
            } catch (RuntimeException exception) {
                stockRepository.increase(sku, quantity);
                throw exception;
            }
        }
    }

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

    @BeforeEach
    void setUp() {
        stockRepository = new FakeStockRepository();
        paymentGateway = mock(PaymentGateway.class);
        service = new OrderService(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_stock_not_enough() {
        stockRepository.put("A", 2);

        assertThatThrownBy(() -> service.place("A", 5))
                .isInstanceOf(IllegalStateException.class)
                .hasMessage("库存不足:2 < 5");

        assertThat(stockRepository.stockOf("A")).isEqualTo(2);
        verify(paymentGateway, never()).charge(org.mockito.ArgumentMatchers.any());
    }

    @Test
    @DisplayName("支付失败:库存回滚")
    void rolls_back_stock_when_payment_fails() {
        stockRepository.put("A", 10);
        doThrow(new IllegalStateException("支付网关不可用"))
                .when(paymentGateway).charge(new Payment("A", 3));

        assertThatThrownBy(() -> service.place("A", 3))
                .isInstanceOf(IllegalStateException.class)
                .hasMessage("支付网关不可用");

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

换成"全 Mock"方案的对比:

java
// 全 Mock:需要手工同步"库存状态"
when(stockRepository.stockOf("A")).thenReturn(10);        // 第一次查
// place 内部:decrease 后没有再查,所以只需在回滚场景再打一次桩
when(stockRepository.stockOf("A")).thenReturn(10);        // 回滚后再查——必须再写一遍
assertThat(stockRepository.stockOf("A")).isEqualTo(10);

关键差别:

Fake 方案全 Mock 方案
断言内容stockOf("A") 的真实返回值打桩时写的数字
能否发现"少扣一次"能(状态会不符)不能(decrease 调用被 verify 了就通过)
代码量一个 Fake 类(可复用)每个测试多几行桩

结论:"被测代码会改的状态"用 Fake,"只被调用一次就结束的边界"用 Mock。 这条经验能解决大部分选择困难。

13. 掌握标准 ​

  • [ ] 能说出五种替身的定义与各自擅长的场景;
  • [ ] 能用 Lambda 手写 Dummy / Stub / Spy,用 Map 手写 Fake;
  • [ ] 知道 spy() 默认执行真实逻辑,理解它与 mock()、手写 Spy 的差别;
  • [ ] 遇到"需要手工同步打桩来模拟状态"时,知道该换成 Fake;
  • [ ] 能说出新 JDK 下 mock 具体类失败的原因与解法。

14. 扩展资源 ​

15. 下一步学习 ​

《Mockito 进阶:ArgumentCaptor 与依赖注入》:把替身用得更精确。

后续知识

暂无

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

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