3. 测试替身与Mockito

在单元测试中,被测对象往往依赖数据库、HTTP 服务、时间、随机数或第三方 SDK。如果每次都启动真实依赖,测试会变慢、变脆,甚至无法稳定复现。测试替身(Test Double)就是用来替代真实协作者的“演员”。它不是一个具体类,而是一组角色的统称:Stub、Spy、Mock、Fake 各自承担不同的职责。理解它们的边界,比记住 Mockito 的 API 更重要。

四类替身的职责差异

Stub(桩)只负责“给答案”。当被测代码调用某个方法时,Stub 返回预先设定的值,不关心调用了几次、顺序如何。例如查询用户服务时固定返回一个 User 对象。它适合隔离外部数据,让测试聚焦于被测逻辑的分支。

Mock(模拟对象)除了能返回预设值,还负责“验证交互”。它记录方法被调用的次数、参数、顺序,测试结束时通过 verify 断言这些交互是否符合预期。Mock 关注的是“被测对象有没有正确地与协作者通信”,而不是协作者内部如何实现。

Spy(间谍)是对真实对象的包装。默认情况下,Spy 会调用真实方法,只有被显式打桩的方法才返回预设值。它适合“大部分逻辑走真实实现,只替换少数难以构造或副作用大的方法”。例如对一个真实的价格计算器打桩汇率查询,其余计算仍走真实代码。

Fake(伪实现)是一个轻量级的真实实现,通常用内存数据结构替代外部系统。最典型的是内存版 Repository:用 HashMap 模拟数据库的增删改查。Fake 有真实行为,但不具备生产环境的持久化、事务和网络特性。它适合集成测试或需要多次读写交互的场景。

测试替身四象限:行为真实度 × 交互验证能力 行为真实度 → 交互验证能力 → Stub 桩 只给答案,不验证交互 when(...).thenReturn(...) 低真实度 · 低验证力 Mock 模拟对象 预设答案 + 验证调用 verify(...).method(...) 低真实度 · 高验证力 Spy 间谍 默认走真实方法,局部打桩 spy(realObj) / @Spy 高真实度 · 中验证力 Fake 伪实现 内存版真实行为实现 InMemoryUserRepository 高真实度 · 低验证力 选择原则:只验证“有没有正确调用”用 Mock;只提供“固定返回值”用 Stub;保留真实逻辑用 Spy;需要真实读写行为用 Fake

Mockito 核心 API 与使用方式

Mockito 是 Java 生态中最常用的 Mock 框架。创建 Mock 有两种方式:Mockito.mock(UserService.class) 或注解 @Mock 配合 @ExtendWith(MockitoExtension.class)。打桩使用 when(mock.method(args)).thenReturn(value),验证使用 verify(mock).method(args)。对于连续调用返回不同值,可以用 thenReturn(a, b, c) 或 thenThrow 模拟异常。

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    private PaymentGateway paymentGateway;

    @Spy
    private PriceCalculator priceCalculator = new PriceCalculator();

    @Test
    void should_mark_order_paid_when_payment_succeeds() {
        // Stub:固定返回支付成功
        when(paymentGateway.charge(anyString(), eq(10000L)))
                .thenReturn(PaymentResult.success("TXN-001"));

        // Spy:只替换汇率查询,其余计算走真实逻辑
        doReturn(7.2).when(priceCalculator).getExchangeRate("USD");

        OrderService service = new OrderService(paymentGateway, priceCalculator);
        Order order = service.createOrder("USD", 10000L);

        assertEquals(OrderStatus.PAID, order.getStatus());
        // Mock:验证交互
        verify(paymentGateway, times(1)).charge("USD", 10000L);
        verify(paymentGateway, never()).refund(anyString());
    }
}

这段代码同时展示了三种替身:paymentGateway 是 Mock,既被打桩又做交互验证;priceCalculator 是 Spy,真实计算逻辑保留,只把汇率查询替换为固定值;如果再加一个内存版 OrderRepository,那就是 Fake。注意 eq(10000L) 与 anyString() 的混用:一旦使用参数匹配器,所有参数都必须用匹配器,否则 Mockito 会抛出 InvalidUseOfMatchersException。

常见陷阱与规避策略

陷阱一:过度 Mock。把每个依赖都 Mock 掉,测试就变成“验证 Mock 的调用脚本”,而不是验证业务逻辑。判断标准是:如果重构内部实现导致测试大量失败,但外部行为没变,说明 Mock 粒度太细。优先 Mock 跨边界依赖(网络、数据库、时钟),对纯计算逻辑使用真实对象或 Spy。

陷阱二:Spy 打桩用错语法。对 Spy 使用 when(spy.method()) 会先调用真实方法,可能产生副作用或异常。正确做法是 doReturn(value).when(spy).method(),这样不会触发真实调用。

陷阱三:Mock 默认返回“空值”。Mock 对象对未打桩的方法返回 null、0、false 或空集合。如果被测代码直接对返回值做链式调用,就会抛 NullPointerException。解决方式是显式打桩,或使用 RETURNS_DEEP_STUBS(但应谨慎,它会掩盖设计问题)。

陷阱四:验证顺序与次数过于严格。verify(mock, times(1)) 是合理的,但 InOrder 验证和 atLeastOnce 的滥用会让测试与实现细节耦合。除非顺序本身是业务契约,否则不要验证顺序。

易错点:在 @BeforeEach 中对 Mock 打桩,但某些测试用例并不需要该桩,Mockito 严格模式(MockitoExtension 默认 STRICT_STUBS)会报 UnnecessaryStubbingException。应把打桩移到具体测试方法内,或使用 lenient() 显式放宽,但后者不应成为常态。

如何选择:一张决策表

场景 推荐替身 理由
需要固定返回值,不关心调用 Stub 最简单,无验证负担
需要验证“是否调用了支付接口” Mock 记录交互,支持 verify
真实计算逻辑 + 替换单个外部查询 Spy 保留真实行为,局部打桩
需要多次读写、模拟数据库行为 Fake 内存实现,行为真实且快
验证异常分支 Mock/Stub thenThrow 模拟异常

最后强调一个工程习惯:替身的选择应服务于测试意图。如果测试目标是“验证订单状态流转”,那么支付网关用 Stub 返回成功即可,不必 verify;如果测试目标是“确保退款时调用了支付网关”,那就必须用 Mock 并 verify。把替身类型与测试意图对齐,才能让单元测试既稳定又有表达力。