替身证明了什么:宽泛匹配、契约测试与录制的响应
取消订单的单元测试全绿,上线后所有取消请求都返回「订单不存在」。原因是服务用客户 id 去查了订单,而测试里的桩写的是
when(repo.find(any())):不管传什么参数,都返回准备好的订单。
测试替身(桩、模拟对象、伪实现)让我们在没有数据库、没有下游服务的情况下测试一段代码。它的代价是:测试证明的只是「代码在替身描述的那个世界里是对的」。替身描述得越宽泛,测试能发现的问题越少;替身与真实实现的行为不一致,测试证明的就是一个不存在的世界。
本文用 JUnit 6.1.3、Mockito 5.24.0 和 H2 2.4.240,看三种常见的替身怎样让测试失去证明力,以及对应的做法。实验见文末。
一、宽泛的匹配器:桩接受了错误的参数
Order o = repo.find(customerId).orElseThrow(...); // 缺陷:应该是 orderIdwhen(repo.find(any())) ……………………………… 宽松模式 通过
when(repo.find("o-1")) ……………………………… 宽松模式 错误:订单不存在
when(repo.find("o-1")) ……………………………… 严格桩 错误:Strict stubbing argument mismatch … repo.find("c-9")any() 让桩对任何参数都返回同一个订单,服务传错了参数,测试照样通过。这条测试实际上证明的是「如果仓储返回了这个订单,服务能正确判断能否取消」,而「服务用正确的 id 去查」这件事完全没有被检查。
用精确的参数写桩,缺陷就暴露了:桩对 "c-9" 没有定义,返回空,服务抛出「订单不存在」。Mockito 的严格桩(MockitoExtension 的默认模式)更进一步:桩定义的参数与实际调用不一致时直接报错,并指出实际调用的位置和参数,不需要再从「订单不存在」反推原因。严格桩还会把定义了却没被用到的桩报告为错误,防止测试里堆积与场景无关的准备代码。
规则:桩的参数要和测试场景里的数据一致。any() 只用于那些确实与被测行为无关的参数,比如日志上下文。
二、手写的伪实现:和真实实现不是一回事
内存实现的仓储写起来很快,测试也跑得快。问题在于它是否遵守了接口的契约:
契约:按客户查询,按创建时间倒序 H2 实现 通过 内存替身 失败:["o-1", "o-2", "o-3"],期望 ["o-2", "o-3", "o-1"]
契约:重复 id 被拒绝 H2 实现 通过 内存替身 失败:没有抛出异常(直接覆盖了)内存替身按插入顺序返回、重复 id 直接覆盖。依赖排序的业务代码在替身上测试通过,在生产上的行为不同;依赖「重复 id 报错」来防止重复提交的代码,在替身上永远测不出问题。
做法是为接口写一组契约测试,替身和真实实现都要通过:契约测试是一个抽象测试类,子类只提供被测的实现。真实实现用真实的数据库运行(这里用 H2 演示,生产数据库最好用同一种数据库),替身用内存运行。契约测试失败,说明替身需要修正,而不是业务测试需要调整。这样,业务测试使用替身得出的结论,才能迁移到真实实现上。
三、录制的响应:证据不是契约
调用外部服务的客户端,常见的测试方法是把一次真实调用的响应录下来,测试时用它做替身:
用录制的响应 {"status":"PAID"} 做替身 通过
对方现在返回 {"state":"PAID"} 失败:解析为 UNKNOWN录下来的响应是一份证据:它证明了对方在录制的那一刻返回了这种格式。它不是契约:对方没有承诺以后一直这样返回。半年后对方把字段名从 status 改成了 state,录制替身上的测试仍然全绿,生产上客户端把所有支付都解析成了未知状态。
替身只能证明它所建模的假设。要让这个假设有依据,有几种做法:
- 与提供方共享契约:提供方把对外承诺的格式写成契约测试,每次发布前在自己那边验证;消费方的替身从同一份契约生成。消费者驱动的契约测试就是这种做法。
- 定期对真实服务跑少量集成测试:不在每次构建时跑,但要能发现替身与现实之间的漂移。
- 客户端对未知格式报错而不是返回默认值:解析不到
status时抛出异常,比悄悄返回UNKNOWN更容易被发现。
网络层的替身(模拟服务器、录制回放工具)同理:它们证明的是客户端在某种网络行为下的表现,例如超时、重试、错误码;对方是否真的会这样表现,需要另外的证据。
四、怎样选择替身
| 替身 | 适合 | 证明力的来源 |
|---|---|---|
| 桩(返回固定值) | 隔离与被测行为无关的依赖 | 参数精确、严格桩 |
| 模拟对象(校验调用) | 调用本身是需求(发送通知、不重复扣款) | 校验的是需求,不是实现步骤 |
| 伪实现(内存仓储) | 需要状态、需要多次交互的测试 | 与真实实现共享契约测试 |
| 录制的响应 / 模拟服务器 | 外部服务的客户端 | 共享契约或定期对真实服务验证 |
测内部调用次数的问题,在从行为到场景到断言里有实测:重构后行为不变,校验调用次数的测试却失败了。
五、检查清单
- 桩的参数与测试数据一致;默认使用严格桩。
- 模拟对象只校验属于需求的调用。
- 伪实现与真实实现跑同一组契约测试。
- 录制的响应只当作证据;契约由提供方承诺并验证。
- 客户端遇到不认识的格式时报错,而不是返回默认值。
配套实验
- codesphere-labs/testing/test-doubles:JUnit 6.1.3、Mockito 5.24.0、H2 2.4.240、JDK 25.0.4.1:any() 与精确参数、严格桩、内存替身与 H2 实现的契约测试、录制响应与新格式(验证记录)
参考资料
- Martin Fowler,Mocks Aren't Stubs
- Martin Fowler,Consumer-Driven Contracts
- Mockito 文档:Strictness
- Gerard Meszaros,《xUnit Test Patterns》:Test Double