3134 字
约 10 分钟
18
S16-A08 · 契约测试与 mock 边界:谁该被替身
2024-12-31
2026-11-09

"单元测试把下游全 mock 了,断言全绿,联调第一天就炸。"——这不是 mock 写得不认真,而是替身放错了位置。mock 让测试变绿的能力,正好等于它掩盖问题的能力。

判断谁该被替身,只有一条主判据,其余都是它的推论:这个依赖是否可能出错、而我的测试仍然全绿。可能,就必须让真实的它或者它的契约出现;不可能,替身才是合理的。


1. 三类误用

误用 表面现象 掩盖了什么 典型修法
mock 掉被依赖方,却断言调用次数 verify(repo, times(1)) 全绿 结果是否正确无人验证 断言状态变化,不锁调用序列
mock 掉本可跑的真实实现 内存仓库被换成 when(...) 实现自身的逻辑错误 能跑真实现就用 fake 或真实例
mock 出永不存在的返回 下游返回了它不会返回的结构 协议理解偏差,联调必炸 用契约或真实响应样本

第三类最难发现:它不违反任何本地规则,只是替身与真实系统的假设不一致。这类问题的成本不在修复,而在于它总要等到联调、甚至等到生产才暴露。

2. 判据:可能出错且测试仍绿

依赖 会出错吗 单测是否会绿 该用什么
纯计算工具类(时间格式化、金额换算) 会 否,直接断言输出 真实例
数据库访问层 会(SQL、映射、事务) 是,mock 后什么都绿 真库(容器)或 fake 仓储 + 少量真库用例
外部支付/短信网关 会,但不在你控制内 是 fake,契约用录制样本维护
内部下游服务 会(字段、类型、语义) 是 契约测试
消息中间件 会(序列化、幂等、顺序) 是 真实例(容器)
时间、随机数 会(跨时区、边界) 是 注入可控实现

判据落地成一句话:"如果它坏了,我的测试会不会红?" 答"不会",就说明这里缺的不是 mock,而是能发现它坏掉的验证。

3. 替身的四个层次与成本

分类定义见 S16-J90,这里只算成本与适用面。

层次 建造成本 维护成本 能发现的问题 适用位置
stub 极低 低(但会随协议漂移) 几乎不能 只想让分支跑通
mock 低 中(断言绑定调用细节) 交互形态变化 验证协作关系
fake 中(要写实现) 中(要跟协议一起改) 部分真实逻辑 仓储、网关、消息发送器
真实实例 高(起服务/容器) 低(不用维护替身) 协议、SQL、序列化全部 边界处、关键路径

成本曲线的形状值得注意:stub 最便宜但最容易腐烂,因为没有人负责让它跟协议同步;真实实例最贵的一次性投入,但把"同步"这件事交给了上游本身。中间的 fake 是唯一需要持续维护的资产,也因此最需要明确归属。

4. 契约测试解决的是"同一份协议的理解"

层次 回答的问题 不回答
单元测试 我的逻辑对不对 协议是否一致
契约测试 提供方是否仍满足调用方的假设 值在业务上是否正确
集成测试 两个真实实现接起来能不能跑 上线顺序是否安全
E2E 用户路径是否通 哪个字段变了

契约的独特价值在于它是唯一能在"对方不在场"时验证对方假设的手段:提供方跑消费者给的契约,不需要起调用方;调用方拿提供方的真实响应验证自己的解析,不需要起提供方。集成测试做不到这一点,它要求两边同时在场。

5. 消费者驱动契约的落地成本

问题 现实答案 成本
谁发布契约 消费者在自己流水线里发布 每个消费侧都要加一步
谁验证 提供方在 CI 里跑验证 提供方要为每个消费者保留验证任务
失败怎么处理 提供方阻断合并,或降级为告警 阻断会拖慢提供方,告警会腐烂
谁负责清理 消费者下线要撤契约 无人负责时会积累僵尸契约
# 消费侧:测试后发布契约,带版本与分支标签
./gradlew pactPublish -Ppact.broker.url=$BROKER -Ppact.provider.version=$GIT_SHA
# 提供侧:验证全部消费者的期望,再决定能否上线
./gradlew pactVerify -Ppact.provider.tag=main

真正的成本不在工具,而在归属:契约是一份跨团队的共享资产,没有明确 owner 就会退化成"上次能过就行"的形式主义。判据是——如果契约失败,是否有人必须停下手中的事来处理。

6. 内部服务与第三方接口:两套策略

维度 内部服务 第三方接口
对方能否配合改 能 不能
主要风险 对方升级导致不兼容 对方行为不透明、限流、字段口径
手段 契约测试 + 版本兼容门禁 fake + 录制样本 + 边界值用例
契约由谁维护 消费者 你(从文档与真实响应反推)
失效信号 验证失败阻断发布 样本过期,fake 与真实行为脱节
// 第三方 fake:用录制样本而不是手写返回值
{ "request": { "method": "POST", "url": "/v1/pay" },
  "response": { "status": 200, "json": { "trade_no": "T20261109001", "state": "PAID" } } }

第三方必须 fake——你无法让对方跑你的验证。但 fake 的样本要来自真实响应并定期重录,否则它验证的是你对文档的想象。

7. 数据库要不要 mock

关注点 mock 仓储 真实数据库 判据
SQL 语法与方言 不验证 验证 有手写 SQL 就必须真库
映射与类型转换 不验证 验证 ORM 复杂映射必须真库
事务与隔离级别 不验证 验证 涉及并发写必须真库
查询计划与索引 不验证 部分验证 性能问题另测
业务分支覆盖 快、可控 慢、需造数 纯分支逻辑用 fake 更快
-- 换库跑测试的隐蔽偏差:H2/SQLite 与 MySQL 在语义上不等价
GROUP BY 宽松模式:MySQL 允许未聚合列,其他库报错
字符串比较:大小写敏感性、尾部空格处理不同
时间类型:时区与精度截断行为不同

结论:凡是有手写 SQL 或复杂映射的地方,SQL 正确性只能由真库验证;mock 仓储只用于验证调用方的分支逻辑,不能作为"数据层正确"的证据。用内存库替代 MySQL 是一种半真库,偏差恰好落在最容易出事的语义上。

8. 隐式依赖的处理顺序

隐式依赖 处理手段 顺序理由
时间 注入 Clock / 可控时间源 最先,影响面最大且最容易复现
随机数 注入 Random 或固定种子 其次,影响断言稳定性
网络 fake 客户端或本地 stub 服务 再次,失败模式多但可隔离
文件系统 临时目录 + 显式清理 最后,成本低、影响局部
环境变量/配置 显式构造配置对象 与时间同级,但改动小
// 时间不要 static 取,注入进来才能在测试里拨动
class OrderExpiry {
  private final Clock clock;
  OrderExpiry(Clock clock) { this.clock = clock; }
  boolean expired(Order o) { return o.createdAt.plusHours(24).isBefore(Instant.now(clock)); }
}
// 测试:Clock.fixed(Instant.parse("2026-11-09T10:00:00Z"), ZoneOffset.UTC)

顺序的理由是改动成本 vs 影响面:时间与随机数一旦在代码里被 static 调用,后续所有测试都在和它对抗;先解决它们,后面的替身才有意义。

9. 测试容器的成本与数据偏差

项 收益 成本
起真实数据库 SQL、事务、约束全部可信 启动秒级到十几秒,CI 需要 Docker
起真实消息中间件 序列化、确认、重投可信 资源占用高,清理易残留
共享容器(一次起、全测试复用) 快 状态污染,测试顺序耦合
每用例独立容器 隔离好 慢,CI 成本陡增
@Container
static MySQLContainer<?> db = new MySQLContainer<>("mysql:8.0")
    .withDatabaseName("app").withInitScript("schema.sql");

表结构与真实库的偏差同样要算:容器里的 schema 若靠测试专用脚本维护,就会与迁移脚本分叉。判据是"schema 只有一个来源",测试容器要么跑真实迁移,要么就不要声称验证了数据库。测试数据也一样——手工造的数据往往比真实数据干净,边界值、空值、超长字段这些恰恰是线上故障的来源。

10. 排错

现象 最可能原因 核对方式
单测全绿、联调必炸 替身假设与真实协议不一致 抓一次真实响应对比 fake 的返回
契约验证通过但线上仍不兼容 契约没覆盖实际使用的字段 检查契约是否只断言了部分字段
契约长期失败被忽略 失败不阻断发布 改成阻断,或删掉僵尸契约
换库跑测试挂一片 方言/语义偏差 关键用例改真库
测试间互相影响 共享容器状态未清理 每类隔离 schema 或事务回滚
时间相关用例偶发失败 依赖真实时钟 注入 Clock

11. 边界

场景 结论 理由
单模块项目、无跨团队协作 契约测试是过度工程 提供方与调用方是同一人,本地测试即契约
外部依赖极少(一个库、无下游) 不必引入契约 没有"另一方"需要对齐
团队规模小、发布同步进行 集成测试足够 契约的成本买的是异步协作
下游是稳定的基础库(日志、工具) 直接真实例 出错概率低,替身反而增加偏差
只有展示型接口、无严格字段约束 契约收益低 兼容性要求本身不刚性
已有 E2E 覆盖关键路径 契约可减量 但要确认 E2E 真的会因字段变更而红

12. 自测

  1. "它是否可能出错且单测仍绿"这条判据,为什么能覆盖大多数 mock 决策?
  2. mock 掉仓储并断言调用次数,锁住的是什么?重构时会发生什么?
  3. 契约测试能验证字段值在业务上是否正确吗?为什么?
  4. 第三方接口为什么必须 fake 而不能用契约?fake 的样本从哪来?
  5. 有手写 SQL 时,为什么内存库不能作为数据层正确的证据?
  6. 时间、随机数、网络这三类隐式依赖,为什么按这个顺序处理?
S16-A08 · 契约测试与 mock 边界:谁该被替身
https://www.clxhxhhr.top/posts/6194/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。