"单元测试把下游全 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. 自测
- "它是否可能出错且单测仍绿"这条判据,为什么能覆盖大多数 mock 决策?
- mock 掉仓储并断言调用次数,锁住的是什么?重构时会发生什么?
- 契约测试能验证字段值在业务上是否正确吗?为什么?
- 第三方接口为什么必须 fake 而不能用契约?fake 的样本从哪来?
- 有手写 SQL 时,为什么内存库不能作为数据层正确的证据?
- 时间、随机数、网络这三类隐式依赖,为什么按这个顺序处理?