☰
测试替身辨析:Stub、Mock、Fake 的区别与选型指南
2026/10/31 12:02:03 网站建设 项目流程

测试代码写到一定阶段,几乎每个人都会撞上同一堵墙:明明只是验证一个下单逻辑,结果为了跑通测试,硬生生把支付网关、短信服务、库存中心全给“造”了一遍。更让人抓狂的是,团队里三个人对同一个假对象的叫法完全不同,有人叫 mock,有人叫 stub,还有人管它叫 fake,代码评审时鸡同鸭讲,最后干脆全部叫“假数据”了事。这篇内容就想把这三个概念彻底掰开揉碎,讲清楚它们各自解决什么问题、在什么场景下该用哪一个、用错了会埋下什么隐患。不管你是刚写第一行单元测试的新手,还是已经维护着几千个测试用例、正被脆弱测试折磨的老手,都能从这里找到可以直接抄作业的判断标准和落地写法。

1. 三个概念为什么总被混为一谈

1.1 从一次真实的代码评审说起

我印象特别深的一次评审,某同学提交了一个订单服务的测试,代码里有个类叫MockPaymentGateway,点进去一看,它内部维护了一个固定的返回值列表,每次调用按顺序吐出一个结果,既不校验调用次数,也不校验传参。这玩意儿从行为上看明明是个 stub,但名字叫 mock。评审时另一位同学说“你这个 mock 写得不对,应该验证一下调用”,作者反驳“我这就是个假对象,验证什么调用”。两个人吵了十分钟,其实说的根本不是一回事。

这个场景暴露的问题很典型:概念的名字被当成了实现的名字。很多人是先学会了“mock 这个词”,然后把自己写的所有测试替身都叫 mock,至于它实际承担的是“提供数据”还是“验证交互”,完全没想过。而测试替身(Test Double)这个词反而很少有人提,它才是这三者的上位概念——所有用来替代真实依赖的对象,统称测试替身,fake、mock、stub 都是它的具体形态。

所以理清概念的第一步,是把层级关系摆正:测试替身是总称,下面按“替代目的”分成若干类。搞不清层级,就会陷入“mock 和 stub 哪个更好”这种伪命题的争论,因为它们压根不是竞争关系,而是解决不同问题的工具。

1.2 一个生活化的类比:拍电影时的替身演员

要理解这三者的区别,用拍电影打比方最直观。

Stub 像“台词提词器”。演员只需要照着提词器念出预设的台词,提词器不关心演员念得对不对、念了几遍,它只负责在需要的时候给出那句词。对应到代码里,stub 就是“你调用我,我给你一个事先准备好的返回值”,仅此而已,它不记录你调了几次,也不检查你传了什么参数。

Mock 像“片场导演”。导演不光要给演员台词,还要盯着演员:这场戏你必须在第 3 幕出现,必须说这句台词,说错了我就要喊卡。对应到代码里,mock 的核心能力是验证交互——它记录被调用的次数、参数、顺序,测试结束时断言“这个依赖是否按预期被调用了”。

Fake 像“用道具替真车”。拍飙车戏不可能真开一辆法拉利去撞,于是用一辆外观相似、能开动、但内部结构简化的道具车。它有自己的实现逻辑,能真实地跑起来,只是比真车简单。对应到代码里,fake 就是一个有真实行为但实现简化的替代品,最典型的就是内存数据库——它真的能存能取能查,只是数据放在内存里而不是磁盘上。

这个类比的关键在于:三者不是精度高低的关系,而是职责不同。提词器再高级也不会变成导演,道具车再逼真也不负责检查演员走位。

1.3 混用的代价:测试脆弱与误报

概念混用不只是命名难看,它会直接导致测试质量问题,最典型的有两类。

第一类是测试过度耦合实现。用 mock 去验证“内部调用了某个依赖三次”,一旦重构时把三次调用合并成一次批量调用,功能完全没变,测试却红了。这类测试看似严谨,实则把实现细节焊死在了断言里,重构成本极高。很多团队抱怨“测试一改就红、红了就得改测试”,根源往往就是滥用交互验证。

第二类是测试失去意义。用 stub 去替代一个本该验证交互的依赖,比如“下单成功后必须调用一次库存扣减”,结果 stub 只负责返回成功,压根不检查有没有调用库存服务。测试永远是绿的,但线上真出了“下单不扣库存”的 bug,它一点都发现不了。这种测试给人虚假的安全感,比没有测试更危险。

提示:判断一个测试替身用得对不对,有个简单标准——问自己“如果这段被测代码的行为悄悄变了,我的测试会不会红”。如果答案是“不会”,那这个替身大概率用错了地方。

2. 逐个拆解:每种替身到底解决什么问题

2.1 Stub:只给答案,不问过程

Stub 的定位非常纯粹:为被测代码提供可控的输入。它的存在是为了让被测逻辑能顺利往下走,而不必依赖真实的外部环境。比如被测代码要调用一个汇率服务获取当前汇率,测试里不可能真去请求外部接口,于是给一个 stub,固定返回 7.2,这样被测代码就能拿着 7.2 继续算金额。

写 stub 的关键在于“够用就好”。它不需要有内部状态,不需要记录调用历史,甚至不需要处理异常分支——除非你要测的就是异常分支。一个典型的 stub 长这样(以常见的 Java 测试框架思路为例,其他语言同理):

// 伪代码示意:一个只负责返回固定值的 stub public class FixedRateStub implements RateService { @Override public double getRate(String currency) { return 7.2; // 无论传什么币种,都返回 7.2 } }

这里有个容易踩的坑:stub 的返回值要覆盖被测代码真正关心的分支。如果被测代码里有“汇率大于 7 走 A 逻辑,小于 7 走 B 逻辑”,那你至少得准备两个 stub 实例,一个返回 7.5,一个返回 6.8,分别验证两条路径。只准备一个固定值,等于只测了一半。

另一个心得是,stub 尽量写得“笨”一点。有些人喜欢在 stub 里加一堆 if-else 模拟各种场景,结果 stub 本身变成了一个需要被测试的复杂对象。记住 stub 的使命是提供输入,不是模拟整个世界。场景多,就多写几个简单的 stub 实例,而不是写一个万能 stub。

2.2 Mock:盯着你,看你怎么做

Mock 的核心是交互验证。它关心的不是“返回了什么”,而是“被测代码有没有按约定去调用依赖”。典型场景是“副作用必须发生”——下单成功必须发一条消息、注册成功必须写一条审计日志、支付成功必须调用一次对账接口。这些行为的共同点是:返回值可能不重要,但“有没有做”极其重要。

用 mock 时,测试结构通常是“准备—执行—验证”三段式,验证段专门断言交互:

// 伪代码示意:验证库存服务被调用了一次,且参数正确 @Test public void should_deduct_stock_once_when_order_created() { StockService stockMock = mock(StockService.class); OrderService orderService = new OrderService(stockMock); orderService.createOrder(new Order("SKU-001", 2)); verify(stockMock, times(1)).deduct("SKU-001", 2); }

这段代码的价值在于,如果哪天有人把扣库存的逻辑删了或者改错了参数,测试立刻红。但它的风险也在这里:它把“调用一次、参数是这两个”写死成了契约。如果后续重构改成批量扣减,一次调用传一个列表,功能等价但测试必红。所以用 mock 要有节制,只对真正重要的交互做验证,别把每个依赖调用都 mock 一遍。

我个人的经验是,一个测试里 mock 的验证断言最好不超过两三条。超过这个数,说明你在测试里描述的实现细节太多了,测试会变得又长又脆。真需要验证很多交互,往往意味着这段代码职责太重,该考虑拆分了。

2.3 Fake:能跑起来的简化版

Fake 是三者里最容易被低估、但实际价值最高的一种。它有真实的行为逻辑,只是实现比生产版本简单。最经典的例子是内存数据库:生产环境用真实的持久化数据库,测试环境用一个基于哈希表实现的假数据库,接口完全一致,增删改查都能真实工作,只是数据不落盘、重启即失。

Fake 的最大优势是让测试更接近真实。用 stub 和 mock 时,被测代码其实是在和“死”对象打交道,返回什么全凭你设定;而 fake 是“活”的,你存进去什么,读出来就是什么,中间的逻辑真实运转。这让集成度更高的测试成为可能,比如测试一个仓储层的完整读写流程,用内存数据库 fake 就能跑通,而不必连真实数据库。

写 fake 的代价是要自己实现一套逻辑,所以它适合接口稳定、行为相对简单的依赖。像缓存、消息队列、文件存储这类,社区里往往已经有成熟的 fake 实现可以直接用。自己从零写 fake 时,务必守住一条底线:fake 的行为要和真实实现保持一致,尤其是边界情况。如果 fake 对空值的处理和真实数据库不同,那测试通过也说明不了问题,反而会误导人。

2.4 三者对比速查表

把关键差异整理成一张表,选型时对着看最省事:

维度StubMockFake
核心职责提供预设返回值验证交互是否发生提供简化的真实行为
是否记录调用否是通常否
是否有内部逻辑极简或无无(只记录)有,且能真实运转
典型场景隔离外部依赖、构造输入验证副作用、关键调用替代数据库、缓存、队列
主要风险返回值覆盖不全过度耦合实现细节行为与真实实现不一致
维护成本低中中到高

这张表不是让你死记,而是帮你建立判断路径:先问“我要验证的是返回值还是行为”,再问“这个依赖需不需要真实运转”。两个问题答完,用哪种基本就清楚了。

3. 选型决策:什么场景该用哪一个

3.1 从“验证目标”倒推替身类型

选型最靠谱的起点不是“哪个流行”,而是“这个测试到底想证明什么”。我把常见的验证目标归成三类,对应关系很清晰。

第一类:验证计算结果。被测代码拿到输入,经过计算,产出一个结果。这时候外部依赖只是“喂数据”的角色,用 stub 最合适。比如计算运费,依赖一个距离服务,stub 返回固定距离,然后断言运费金额。这里完全不需要关心距离服务被调了几次。

第二类:验证副作用。被测代码执行后,必须对外部世界产生某种影响——发消息、写日志、调接口。这时候用 mock,断言“这个影响确实发生了,且参数正确”。注意只验证真正重要的副作用,别把每个调用都当副作用。

第三类:验证完整流程。被测代码要和依赖来回交互多次,读一点、写一点、再读回来。这种场景 stub 和 mock 都力不从心,因为它们没有真实状态。用 fake,让依赖真实运转起来,测试才有意义。仓储层的读写测试、缓存命中测试都属于这一类。

3.2 一个订单场景的完整选型演示

拿一个下单流程举例,把三类替身放在同一个场景里对比,选型逻辑会更直观。

假设下单流程是:校验库存 → 扣减库存 → 生成订单 → 发送通知。四个依赖分别是库存服务、订单仓储、通知服务、还有一个汇率服务用于计算跨境订单金额。

  • 汇率服务:只用来取一个汇率值参与计算,用stub,固定返回 7.2。
  • 库存服务:扣减是必须发生的副作用,用mock,验证deduct被调用一次且参数正确。
  • 订单仓储:需要真实存取,测试里要能查回刚写入的订单,用fake(内存仓储)。
  • 通知服务:发通知是副作用,但很多团队会选择用 mock 验证“发了一次”,也有人用 fake 记录所有发出的通知再统一断言。两种都行,取决于你想验证的粒度。

这个例子里三种替身同时出现,各司其职,没有谁替代谁。这正是我想强调的:它们本来就是配合使用的,不是二选一。

3.3 什么时候干脆别用替身

有个反直觉的建议:能用真实对象就别用替身。替身是有成本的——写它、维护它、还要保证它和真实实现行为一致。如果一个依赖足够轻量、足够快、足够确定(比如一个纯内存的算法类、一个不涉及 IO 的工具类),直接用真实对象,测试反而更可信。

判断标准可以简化成三条:这个依赖慢不慢(涉及网络、磁盘)、稳不稳(结果是否随机、是否依赖时间)、贵不贵(调用是否收费、是否有配额)。三条里中了一条以上,才考虑上替身。三条都不沾,老老实实用真的。

注意:见过不少团队为了“测试纯粹”,把连字符串工具类都要 mock 一遍,结果测试代码比业务代码还长。这不是严谨,是自找麻烦。

4. 落地实操:从零写出一套清晰的测试替身

4.1 目录与命名约定先行

概念清晰的第一步是命名清晰。我强烈建议在项目里给测试替身单独建一个包或目录,并且用后缀明确类型:XxxStub、XxxFake,mock 因为是框架动态生成的,通常不需要单独建类,但变量名要体现用途,比如stockServiceMock。

命名约定看起来是小事,实际收益很大。新人进项目,看到InMemoryOrderRepositoryFake立刻知道这是个内存版仓储,看到FixedRateStub知道它只提供固定值,不用点进去猜。反过来,如果全叫MockXxx,读代码的人就得逐个看实现才能判断行为,沟通成本陡增。

目录结构上,我习惯这样组织:

test/ doubles/ stub/ // 所有 stub 实现 fake/ // 所有 fake 实现 service/ // 业务测试 repository/ // 仓储测试

这样一眼能看出项目里有哪些替身、各是什么类型,复用也方便。

4.2 手写一个内存仓储 Fake 的完整过程

以订单仓储为例,走一遍 fake 的完整实现。假设生产接口是这样:

public interface OrderRepository { void save(Order order); Order findById(String id); List<Order> findByStatus(String status); }

内存 fake 的实现思路是用一个 Map 当存储,key 是订单 ID:

public class InMemoryOrderRepositoryFake implements OrderRepository { private final Map<String, Order> store = new HashMap<>(); @Override public void save(Order order) { store.put(order.getId(), order); } @Override public Order findById(String id) { return store.get(id); } @Override public List<Order> findByStatus(String status) { return store.values().stream() .filter(o -> status.equals(o.getStatus())) .collect(Collectors.toList()); } }

这段代码不长,但有几个细节值得说。第一,findById找不到时返回null,这必须和真实实现的行为一致——如果真实仓储找不到会抛异常,fake 也得抛,否则测试通过但线上炸。第二,findByStatus用了流式过滤,逻辑简单但真实可用,这正是 fake 的价值所在。第三,store用HashMap而非ConcurrentHashMap,因为单元测试通常单线程,没必要引入额外复杂度,但如果测试涉及并发,就得换。

写完 fake 后,建议给它本身写一两个测试,验证“存进去能取出来”“按状态查得到”。这听起来有点绕——测试替身也要被测试?是的,因为 fake 有真实逻辑,逻辑就可能出错。给 fake 写测试,本质是保证这个“道具”可靠,否则基于它的所有测试都不可信。

4.3 Mock 的验证粒度怎么把握

Mock 用得好不好,全在验证粒度。粒度太粗,测不出问题;粒度太细,测试脆弱。我的经验是抓住“业务上不可省略的交互”来验证。

什么叫不可省略?就是“如果这个调用没发生,业务上就是错的”。下单不扣库存,业务错;下单不发通知,业务可能允许(通知可以异步补发),那通知的调用就不必用 mock 严格验证,用 fake 记录一下、做个宽松断言即可。

具体到写法,验证时优先用“至少一次”而非“恰好一次”,除非业务真的要求幂等。times(1)写起来爽,但一旦引入重试机制就全红。用atLeastOnce()表达“这个副作用必须发生”,语义更稳,也更贴近业务意图。

另外,验证参数时尽量只验证关键字段,别把整个对象做相等比较。对象里加个时间戳字段,相等比较就挂了,但业务上时间戳根本不重要。只断言“订单 ID 和数量对得上”,测试就稳得多。

4.4 让替身可复用的三个小技巧

第一个技巧是给 fake 加清空方法。测试之间共享 fake 实例时,前一个测试的数据会污染后一个,加个clear()在@BeforeEach里调用,问题就解决了。

第二个技巧是stub 用构造参数而非硬编码。与其写死返回 7.2,不如让构造函数接收汇率值,同一个 stub 类就能复用于多个场景,不用为每个值写一个新类。

第三个技巧是mock 的验证逻辑抽成辅助方法。多个测试都要验证“库存扣减正确”,就把这段 verify 抽成一个assertStockDeducted(mock, sku, qty)方法,测试主体更清爽,验证标准也统一。

5. 踩坑实录与排查清单

5.1 那些年我们写错的替身

坑一:stub 返回值不全导致空指针。被测代码里有个分支处理“汇率服务返回 null”的情况,结果 stub 永远返回非 null,这个分支从没被覆盖,上线后真遇到 null 直接崩。教训是:stub 要覆盖被测代码关心的所有输入形态,包括异常和空值。

坑二:mock 验证了不该验证的东西。有个测试验证“日志服务被调用了两次”,后来日志框架升级,内部实现改成批量写,调用次数变了,测试红了但功能没问题。这类测试纯属自找麻烦,日志这种非核心副作用,根本不该用严格 mock 验证。

坑三:fake 和真实实现行为漂移。内存 fake 的findByStatus对大小写敏感,真实数据库配置的是不敏感,测试里查得到、线上一查就空。这种漂移极难发现,因为测试永远是绿的。对策是给 fake 写契约测试,或者定期用真实实现跑一遍同样的用例做对照。

坑四:一个测试里三种替身全上,还互相纠缠。测试又长又难读,出了问题不知道是哪部分导致的。对策是拆测试,一个测试只验证一个关注点,替身数量自然就降下来了。

5.2 常见问题速查表

现象可能原因排查方向
测试永远是绿的,但线上出 bug该验证的交互没验证,stub 掩盖了问题检查关键副作用是否用了 mock 验证
重构后大量测试变红,功能没变mock 验证了实现细节减少 verify 断言,只保留业务关键交互
测试之间互相影响,单独跑能过fake 状态未清理加 clear 方法并在前置钩子调用
fake 测试通过但集成测试失败fake 与真实实现行为不一致补契约测试,对照真实实现
测试代码比业务代码还长替身过度使用评估依赖是否真的需要替身

5.3 几条压箱底的经验

第一条,先写测试再决定替身。别一上来就想“我该用 mock 还是 stub”,先把测试的断言写出来,看断言需要什么,替身类型自然就定了。断言是“结果等于 X”,用 stub;断言是“某方法被调用”,用 mock;断言是“数据能读回来”,用 fake。

第二条,替身越少越好,越简单越好。一个测试里替身超过三个,就该反思是不是被测单元职责太重。替身数量往往是设计问题的信号。

第三条,定期清理不再使用的替身。项目演进中,很多替身会变成孤儿,没人用还占着维护成本。每隔一段时间扫一遍doubles目录,删掉没人引用的,保持整洁。

第四条,团队内统一术语。在代码规范或 README 里写清楚:stub 指什么、mock 指什么、fake 指什么,新人入职先读这段。术语统一了,评审时的无效争论能少一大半。

我在实际项目里推这套约定时,最开始也有人觉得“太较真”,但跑了两三个迭代后,测试的稳定性明显提升,重构时敢改代码了,因为知道测试红了一定是功能真变了,而不是替身写错了。这种“测试可信”的感觉,是靠概念清晰一点点攒出来的,不是靠某个框架或工具能直接给的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询