跳到主要内容
约 9 分钟4 张卡

测试替身

测试替身用可控对象替换被测代码依赖的其他对象,让测试能够稳定复现特定条件。本文先区分 Stub、Mock、Fake 和 Spy,再讲解状态断言、交互验证与替换边界。最后说明 Spy 的配置时机,以及怎样检查实际传入的参数。代码采用 Mockito 5.20.0。

1. 面试核心​

先记住这几条,再读正文
  • Stub 与 Mock。 Stub 通过预设返回值或异常控制测试条件,Mock 则验证被测代码是否按约定调用依赖对象。卡 · 测试替身的种类
  • Fake 与 Spy。 Fake 使用可运行的简化实现保存和处理数据,Mockito Spy 默认执行真实方法并允许局部替换。
  • 集成验证边界。 Mock 验证程序发起了什么调用,真实 SQL、对象映射和数据库约束则需要集成测试验证。
  • 测试与实现耦合。 测试固定内部方法的调用顺序和次数后,即使重构保持业务结果不变,也会让测试失败。卡 · 替换的边界
  • Spy 配置。 doReturn(...).when(spy) 在配置阶段避开真实方法执行。卡 · Spy 的配置阶段调用

2. 测试替身的种类​

选择替身前,需要先确定要控制输入、保存简化状态,还是检查依赖调用。下表按用途区分五种测试替身:

类型主要作用示例
Dummy只填充参数,不参与行为未使用的回调对象
Stub为测试场景返回预设结果固定汇率提供者
Fake有可运行的简化实现内存 Repository
Spy记录真实或包装对象的调用记录发送次数
Mock按期望验证交互验证消息网关收到命令

这些名称描述的是测试用途,而非创建对象的框架方法。即使用 Mockito 的 mock 方法创建对象,只让它返回预设值时,它承担的也是 Stub 角色。

3. Stub​

Stub 又称桩,用预设返回值或异常控制被测代码收到的结果。比如,测试货币换算时,可以让汇率提供者返回固定汇率:

ExchangeRateProvider rates = mock(ExchangeRateProvider.class);
when(rates.rate(USD, CNY, at))
.thenReturn(new ExchangeRate("7.20"));

Price result = service.convert(price, CNY, at);

assertEquals(expected, result);

测试重点是转换结果,Stub 只提供所需汇率。它避免真实网络波动,也能稳定构造超时、缺失和异常等难以复现条件。

真实协议、序列化与客户端配置需用集成或契约测试验证。

4. Mock​

Mock 用来验证被测代码是否按约定调用依赖对象。比如,支付服务需要把支付命令交给网关,测试可以检查这个调用:

PaymentGateway gateway = mock(PaymentGateway.class);

service.pay(command);

verify(gateway).charge(command.idempotencyKey(), command.amount());

这里的 verify 默认检查一次匹配调用,并核对幂等键与金额。以下外部交互约定适合用 Mock 验证:

  • 必须发送一次支付命令。
  • 拒绝请求时不应发送消息。
  • 重试使用同一个幂等键。
  • 业务流程先登记事件,再交给发布端口。

verify 适合检查具有业务含义的外部调用。比如,消息发布端口就是把消息交给外部系统的接口。真实事务提交后的发布时机,还需用集成测试验证。

内部 getter、辅助方法以及无业务意义的调用顺序,应保留实现调整的空间。这样,重构后可观察结果保持不变时,测试也能继续通过。

5. 状态断言与交互断言​

判断测试是否通过,可以检查结果,也可以检查调用。状态断言直接观察返回值或对象状态:

assertEquals(PAID, order.status());

交互断言检查协作者调用:

verify(eventPublisher).publish(any(PaymentAccepted.class));

纯计算和值对象适合验证结果。支付、发消息等外部操作没有方便检查的返回值时,可以通过接口调用判断是否发起了操作。两种断言也可以组合,分别验证业务结果与调用意图;只有大量 verify 时,业务结果本身仍会漏测。

6. 替换的边界​

替身适合隔离难以控制的依赖,让测试集中检查当前对象的业务规则。可以优先考虑以下依赖边界:

  • 时钟与随机源。
  • 支付、邮件、对象存储等外部客户端。
  • Repository 接口。
  • 消息发布端口。
  • 昂贵或不可稳定复现的系统依赖。

下面这些对象适合直接参与测试,替换它们往往会掩盖需要验证的行为:

  • String、Money 等值对象。
  • 当前类内部的私有方法。
  • 简单数据结构。
  • 被测对象本身。
  • 只有一行转发、且 Mock 后没有真实逻辑剩余的组件。

一个类需要十几个 Mock 才能构造时,应检查它是否承担了过多职责。接口若只按数据库、缓存等技术层拆分,也会让调用方依赖过多细节。可以重新检查接口能否围绕完整的业务能力表达。

7. Fake​

Fake 是能够运行的简化实现,适合让多个操作共享状态。比如,内存 Repository 用内存数据结构实现订单的保存和查询,下面展示保存部分:

final class InMemoryOrderRepository implements OrderRepository {
private final Map<OrderId, Order> orders = new HashMap<>();

@Override
public void save(Order order) {
orders.put(order.id(), order);
}
}

上例把订单存入 Map,查询方法还需按接口补齐。后续查询直接读取保存的状态,比为每次调用分别配置 when 更容易表达连续操作。

Fake 的行为会与真实数据库存在差异,例如唯一约束、事务、排序和锁。因此,内存实现能帮助验证上层业务规则,数据库特有的行为仍需真实集成测试覆盖。

8. Spy 与部分 Mock​

Spy 可以观察现有对象的调用,又保留未替换方法的真实行为。Mockito Spy 默认调用真实方法,并允许只替换部分方法,所以适合观测遗留对象。

List<String> spy = spy(new ArrayList<>());

这个 Spy 保留列表的真实行为。若大量方法分别被替换,测试中就会混合真实状态与预设结果,难以判断哪些规则实际运行了。业务代码难以隔离时,可以先提取具有独立职责的依赖对象,再通过它的公开接口控制测试条件。

9. 过度严格的交互验证​

交互验证过细会把实现步骤固定下来,验证过宽又会漏掉错误参数。检查交互断言时,需要留意验证过细或过宽的写法:

  • 验证每个内部调用恰好一次。
  • 使用 inOrder 固定无业务意义的顺序。
  • 对所有参数使用 any(),只检查发生了调用,却漏掉业务字段的值。
  • 对所有调用使用精确对象匹配,连无关字段也固定。
  • 使用 verifyNoMoreInteractions 阻止新增遥测等无害行为。

交互验证应围绕公共契约回答以下问题:

  • 是否调用了目标外部系统?
  • 传入的业务字段是否正确?
  • 重试次数和失败处理是否符合约定? 内部实现调整后,只要这些约定保持不变,测试就应继续通过。

10. Spy 的配置阶段调用​

给 Spy 配置返回值时,需要留意配置表达式本身是否执行了真实方法。Java 会先计算 when 的实参,所以 when(spy.get(0)) 在配置时就访问了列表。

List<String> values = spy(new ArrayList<String>());
// when(values.get(0)).thenReturn("A"); // 运行时越界
doReturn("A").when(values).get(0);
assertEquals("A", values.get(0));

空列表的真实 get(0) 会直接抛出越界异常,预设返回值还没有配置成功。doReturn 则先设置返回行为,再记录目标方法,所以配置阶段会跳过真实 get 调用。

已有 Spy 的方法会执行 I/O 或其他副作用时,也适合这种配置方式。普通 Mock 可以继续使用直观的 when(...).thenReturn(...)。

11. 参数匹配与捕获​

参数匹配器用于描述哪些参数算匹配,例如值相等或类型符合要求。同一次调用一旦使用匹配器,每个参数都需通过匹配器表达。固定值可以用 eq 包装,空值用 isNull 匹配:

verify(gateway).charge(eq(key), any(Money.class));

any(Money.class) 只要求参数是非空的 Money,所以金额错误时也会匹配。需要验证金额内容时,可以用 ArgumentCaptor 记录实际传入的对象,再检查它的值:

ArgumentCaptor<Money> amount = ArgumentCaptor.forClass(Money.class);
verify(gateway).charge(eq(key), amount.capture());
assertEquals(expectedAmount, amount.getValue());

示例捕获实际金额,再通过 assertEquals 与期望值比较。也可以分别检查金额与币种,这样测试就能发现类型正确但业务值错误的调用。

12. 常见问题​

12.1 Mock Repository 能验证真实数据库吗?​

Mock Repository 验证的是上层代码如何响应预设结果。真实 SQL、映射、约束、事务隔离和连接配置,都需要数据库集成测试覆盖。

12.2 Mockito 可以 Mock 私有方法吗?​

12.3 什么时候应该验证调用次数?​

12.4 使用 Mock 后就一定是单元测试吗?​

13 · 面试题卡片​

13.1 Mock 与 Stub 有什么区别,分别什么时候使用​

出现公司:招银网络、爱客科技

考察重点

  • 说明预设返回值与验证依赖调用分别解决什么问题。
  • 说明状态断言和交互断言的选择。
  • 说明哪些真实协议与数据库行为需要集成测试补充。

相关内容:测试替身的种类至替换的边界。

参考回答

Stub 提供预设返回值或异常,用来控制被测对象收到的结果;Mock 则验证依赖是否按约定被调用。

纯计算适合直接断言结果,支付和消息发送等外部副作用则适合检查调用。

时钟、Repository 与远程网关都可以用替身隔离,值对象与私有方法则保留真实行为。Mock 客户端验证的是发起调用的意图,真实协议、序列化、网络配置和数据库约束还需集成或契约测试补充。

13.2 过度 Mock 会带来什么问题​

出现公司:阿里巴巴

考察重点

  • 说明测试固定内部调用的顺序和次数后,为什么等价重构也会使它失败。
  • 说明全部依赖被替换后留下了哪些盲区。
  • 说明大量 Mock 是否暴露职责过多。

相关内容:替换的边界、过度严格的交互验证。

参考回答

过度 Mock 会把内部调用的顺序和次数固定成测试契约,同时留下真实集成行为的盲区。

代码只调整内部实现、业务结果保持不变时,这些测试也会大量失败。

如果所有依赖都被替换,SQL、序列化、Spring 配置和协议兼容性就没有实际执行。测试应围绕公开结果与关键外部交互,并用真实集成和契约测试覆盖这些盲区。一个类需要大量替身时,还要检查它是否承担了过多职责,或依赖接口是否暴露了过多实现细节。

13.3 给空列表 Spy 配置 get(0),为什么 when 写法会直接抛异常?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:解释配置表达式的求值顺序,以及 doReturn 如何避开 Spy 的真实方法调用。

相关内容:Spy 的配置阶段调用。

参考回答

因为 when 的实参会先求值,Spy 的真实 get(0) 在配置完成前就执行了。

空列表没有下标为 0 的元素,所以立即抛出越界异常。

doReturn("A").when(spy).get(0) 会先设置返回行为,记录目标方法时跳过真实调用。这样配置可以完成,之后调用 get(0) 就会返回预设的 "A"。

13.4 any 匹配的测试为何会漏掉错误金额,怎样验证实际参数?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:区分参数类型匹配与业务值校验,并说明怎样捕获实际参数进行断言。

相关内容:参数匹配与捕获。

参考回答

因为 any(Money.class) 只检查参数是非空的 Money,具体金额填错时也会匹配。

可以用 ArgumentCaptor 捕获实际参数,再断言金额与币种是否符合预期。

同一次调用中使用了匹配器,其他参数也要用匹配器表达。比如幂等键固定时用 eq(key),这样既核对目标请求,也能检查实际传入的金额。

14. 参考资料​