测试替身
测试替身用可控对象替换被测代码依赖的其他对象,让测试能够稳定复现特定条件。本文先区分 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 私有方法吗?
Mockito 的常规 API 不支持 Mock 私有方法。测试应通过公开行为验证私有实现,具有独立职责的规则则可以提取为依赖对象。这样测试依赖的是明确契约,内部方法调整时也更容易保持稳定。
12.3 什么时候应该验证调用次数?
次数有业务含义时就应该验证。例如,同一流程只发一次支付请求,通知最多重试三次。内部辅助方法的调用次数由实现决定,保持业务效果即可。
12.4 使用 Mock 后就一定是单元测试吗?
测试层级取决于验证范围和实际运行的依赖。直接构造对象、使用真实值对象的测试,也可以是单元测试。使用了 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),这样既核对目标请求,也能检查实际传入的金额。