一、五个词的分工,其实就一句话

Test Double 的分类(Meszaros 的经典划分)在中文语境里常被笼统说成”打桩”,但五种东西的职责完全不同:

类型 一句话职责 断言对象
Dummy 只为填参数存在,测试里不会被用到 不断言
Stub 返回预先准备好的固定值,让流程走通 被测方法的返回值
Spy 记录被调用情况,测试结束后再检查 被测方法的返回值 + 少量交互
Mock 预先声明”必须以某种方式被调用”,不满足即失败 交互本身(调用次数、参数、顺序)
Fake 一个真的、能跑的简化实现(内存仓储、假网关) 走真实逻辑的结果

那张表里最有用的一列是”断言对象”。社区文章的核心观点我们完全认同:先明确你要断言什么,再选替身——而不是反过来,因为手上有个 Mock 框架就把所有依赖都 Mock 掉。

五种测试替身的边界

二、滥用 Mock 会付出什么代价

Mock 的问题是它把实现细节写进了测试。

// 断言"调用了 3 次数据库查询" —— 这就是把实现钉死
mockRepo.AssertTimes(t, 3).Called("FindById")

一旦有人优化了查询方式,从三次合并成一次——功能更好了,测试却红了。这类”改动明明是对的,测试却在阻止你”的体验,是所有团队最终放弃维护单测的起点。我们在给客户做遗留系统改造时,接手过测试通过率 90%、但覆盖率只有 15% 的项目,翻开细看几乎全是这种交互断言。

判断标准其实很简单:如果这个断言在业务人员看来没有意义(”客户只关心订单能不能下,不关心中间查了几次库”),那它就不该是 Mock 断言。

三、我们的选型顺序(从便宜到贵)

写测试时按这个顺序往下走,能停就停:

  1. 能用 Dummy 就别写别的——参数不能为 nil,但你根本不关心它,传个空对象。
  2. 只关心”给定输入返回什么”,用 Stub。 绝大多数纯逻辑分支测试走这条。
  3. 依赖行为本身不重要、但需要一个能跑的存储,用 Fake。 内存版 repository、SQLite 内存库都属此类。这是我们最推荐、也最少被使用的一档。 Fake 让测试走真实逻辑,重构时不会误报。
  4. 要看”有没有被调用”(比如审计事件、外部通知),用 Spy。 注意:只记录,不预设次数。
  5. 只有当”交互契约本身就是需求”时才用 Mock。 例如:调用第三方支付必须幂等重试不超过 3 次、下单必须发短信——这些是业务承诺,不是实现细节,才值得用 Mock 断言。

四、什么时候”别打桩”

有三种情况我们建议直接测真实依赖:

  • 数据库交互。 内存 Fake 与真库在事务边界、唯一约束、日期与时区行为上并不等价。涉及这些的仓储层,我们宁可跑一个容器里的真库,也不要一个永远绿的假测试。
  • 序列化 / JSON 契约。 Mock 掉编解码等于测不到线上最常见的那类故障。
  • 框架自身的中间件链。 这类问题只在真实请求生命周期里暴露。

一句话总结:替身是为了隔离不确定性,不是为了省启动时间。 如果依赖本身行为稳定、又恰好是你想验证的东西,打桩只是在制造盲区。

五、给正在写 AI 应用测试的团队补一句

我们今年一半以上的项目是大模型应用。这里有个新问题:模型输出天然不可复现,把 LLM 客户端 Mock 掉是最省事的做法,但它同时把”提示词工程是否有效”这件事排除在测试之外了。

我们的折中是三层:

  1. 契约层用 Stub:固定返回一段已知文本,测解析、重试、超时——这部分必须是确定性的;
  2. 效果层用真实小样本:几十条标注样例跑离线评估,不放进每次 CI,而是每天定时跑;
  3. 红线层用 Spy:断言”敏感字段绝没有出现在送出模型的 prompt 里”——这类是安全承诺,值得钉死。

分层之后,测试既不会因为模型抖动而随机失败,也不会因为全 Mock 而什么都没测到。


五种 Test Double 的职责划分沿用 Meszaros 经典分类;选型顺序与 AI 应用测试分层为洹拓科技交付实践。

新乡洹拓科技有限公司,软件开发 · AI模型研发 · 云服务与部署运维。

咨询热线:400-829-9957
地址:河南省新乡市辉县市金城百货四楼