Skip to content

替身证明了什么:宽泛匹配、契约测试与录制的响应 ​

取消订单的单元测试全绿,上线后所有取消请求都返回「订单不存在」。原因是服务用客户 id 去查了订单,而测试里的桩写的是 when(repo.find(any())):不管传什么参数,都返回准备好的订单。

测试替身(桩、模拟对象、伪实现)让我们在没有数据库、没有下游服务的情况下测试一段代码。它的代价是:测试证明的只是「代码在替身描述的那个世界里是对的」。替身描述得越宽泛,测试能发现的问题越少;替身与真实实现的行为不一致,测试证明的就是一个不存在的世界。

本文用 JUnit 6.1.3、Mockito 5.24.0 和 H2 2.4.240,看三种常见的替身怎样让测试失去证明力,以及对应的做法。实验见文末。

宽泛匹配器伪实现录制的响应when(find(any()))缺陷没有被发现when(find("o-1"))订单不存在严格桩参数不匹配:find("c-9")契约:按时间倒序H2 通过|内存替身失败契约:重复 id 报错H2 通过|内存替身失败替身和真实实现跑同一组契约测试录制 {"status":"PAID"}替身上的测试通过对方现在 {"state":"PAID"}真实调用解析为 UNKNOWN证据 ≠ 契约共享契约或定期对真实服务验证替身只能证明它所建模的假设:桩的参数要与场景数据一致;伪实现要通过与真实实现相同的契约测试;录制的响应要有提供方承诺的契约做依据模拟对象只校验属于需求的调用(发通知、不重复扣款),不校验实现步骤
图 1 · 实测(JUnit 6.1.3、Mockito 5.24.0、H2 2.4.240):服务用客户 id 查订单的缺陷,在 any() 的桩下测试通过,精确参数与严格桩把它暴露出来。同一组契约测试在 H2 实现上通过、在内存替身上两项失败,说明替身描述的不是真实的仓储。录制的响应证明的是过去的格式,对方改了字段名后,替身上的测试仍然通过。

一、宽泛的匹配器:桩接受了错误的参数 ​

java
Order o = repo.find(customerId).orElseThrow(...);   // 缺陷:应该是 orderId
text
when(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() 只用于那些确实与被测行为无关的参数,比如日志上下文。

二、手写的伪实现:和真实实现不是一回事 ​

内存实现的仓储写起来很快,测试也跑得快。问题在于它是否遵守了接口的契约:

text
契约:按客户查询,按创建时间倒序      H2 实现 通过    内存替身 失败:["o-1", "o-2", "o-3"],期望 ["o-2", "o-3", "o-1"]
契约:重复 id 被拒绝                  H2 实现 通过    内存替身 失败:没有抛出异常(直接覆盖了)

内存替身按插入顺序返回、重复 id 直接覆盖。依赖排序的业务代码在替身上测试通过,在生产上的行为不同;依赖「重复 id 报错」来防止重复提交的代码,在替身上永远测不出问题。

做法是为接口写一组契约测试,替身和真实实现都要通过:契约测试是一个抽象测试类,子类只提供被测的实现。真实实现用真实的数据库运行(这里用 H2 演示,生产数据库最好用同一种数据库),替身用内存运行。契约测试失败,说明替身需要修正,而不是业务测试需要调整。这样,业务测试使用替身得出的结论,才能迁移到真实实现上。

三、录制的响应:证据不是契约 ​

调用外部服务的客户端,常见的测试方法是把一次真实调用的响应录下来,测试时用它做替身:

text
用录制的响应 {"status":"PAID"} 做替身      通过
对方现在返回 {"state":"PAID"}              失败:解析为 UNKNOWN

录下来的响应是一份证据:它证明了对方在录制的那一刻返回了这种格式。它不是契约:对方没有承诺以后一直这样返回。半年后对方把字段名从 status 改成了 state,录制替身上的测试仍然全绿,生产上客户端把所有支付都解析成了未知状态。

替身只能证明它所建模的假设。要让这个假设有依据,有几种做法:

  • 与提供方共享契约:提供方把对外承诺的格式写成契约测试,每次发布前在自己那边验证;消费方的替身从同一份契约生成。消费者驱动的契约测试就是这种做法。
  • 定期对真实服务跑少量集成测试:不在每次构建时跑,但要能发现替身与现实之间的漂移。
  • 客户端对未知格式报错而不是返回默认值:解析不到 status 时抛出异常,比悄悄返回 UNKNOWN 更容易被发现。

网络层的替身(模拟服务器、录制回放工具)同理:它们证明的是客户端在某种网络行为下的表现,例如超时、重试、错误码;对方是否真的会这样表现,需要另外的证据。

四、怎样选择替身 ​

替身适合证明力的来源
桩(返回固定值)隔离与被测行为无关的依赖参数精确、严格桩
模拟对象(校验调用)调用本身是需求(发送通知、不重复扣款)校验的是需求,不是实现步骤
伪实现(内存仓储)需要状态、需要多次交互的测试与真实实现共享契约测试
录制的响应 / 模拟服务器外部服务的客户端共享契约或定期对真实服务验证

测内部调用次数的问题,在从行为到场景到断言里有实测:重构后行为不变,校验调用次数的测试却失败了。

五、检查清单 ​

  • 桩的参数与测试数据一致;默认使用严格桩。
  • 模拟对象只校验属于需求的调用。
  • 伪实现与真实实现跑同一组契约测试。
  • 录制的响应只当作证据;契约由提供方承诺并验证。
  • 客户端遇到不认识的格式时报错,而不是返回默认值。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权