news 2026/10/31 3:26:31

Fakes、Mocks、Stubs 分不清?一文讲透测试替身选型与实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Fakes、Mocks、Stubs 分不清?一文讲透测试替身选型与实战

测试代码写到一定阶段,几乎每个人都会撞上同一堵墙:明明只是想验证一个分支逻辑,结果为了跑通一个函数,不得不把数据库、消息队列、第三方接口全部拉起来,测试跑一次要等半分钟,改一行代码重跑一遍,心态直接崩掉。这时候老手通常会甩过来一句“你用 fake 或者 stub 顶一下不就行了”,可等你真去翻文档,发现 Fakes、Mocks、Stubs 这几个词被各种文章混着用,同一个东西在不同框架里叫法还不一样,越看越糊涂。

这篇内容就是把这个结解开。我打算从一线写测试的实际场景出发,把Fakes、Mocks、Stubs这三个概念掰开揉碎,讲清楚它们各自解决什么问题、边界在哪里、什么时候该用哪个,再配上可以直接抄的代码示例和踩坑记录。不管你是刚接触单元测试的新手,还是已经写了一堆测试但总觉得“测试很脆、改一点就红”的老手,都能从里面找到对自己有用的部分。核心关键词就三个:测试替身、行为验证、状态验证,后面所有内容都围绕它们展开。

1. 先把测试替身这件事讲透

1.1 为什么需要测试替身

写单元测试的核心诉求是“隔离”。一个函数如果依赖了外部的东西,那这个测试就不再是单元测试,而是集成测试。外部依赖包括但不限于:数据库连接、HTTP 接口、文件系统、系统时间、随机数生成器、消息队列。这些东西有两个共同特点:慢,以及不确定。

慢很好理解,一次数据库往返几十毫秒,一个测试里调十次就半秒,几百个测试跑下来就是几分钟。不确定更麻烦,第三方接口可能超时、可能返回脏数据、可能今天维护明天限流,你的测试结果就跟着飘。今天绿明天红,团队里没人再信这套测试,最后大家集体绕过 CI 直接合并,测试形同虚设。

测试替身(Test Double)就是解决这个问题的通用手段。这个词是个统称,指的是“在测试中替代真实依赖的任何对象”。就像拍电影时的替身演员,真正危险的、昂贵的、不可控的镜头交给替身完成,主角只需要在关键镜头露脸。测试里的替身也一样,把不可控的依赖换成一个你能完全掌控的假对象,让被测代码以为自己在跟真依赖打交道。

提示:测试替身是统称,Fakes、Mocks、Stubs 都是它的子类型。很多文章把这三个词当同义词用,这是概念混乱的源头,后面会逐个区分。

1.2 三个概念的一句话定义

先把结论摆出来,后面再展开:

  • Stub(桩):给被测代码提供预设的返回值,不关心被测代码怎么用它。你问它要数据,它给你一份写死的数据,仅此而已。
  • Mock(模拟对象):除了能返回预设值,还会记录被测代码对它的调用行为,测试结束后断言“某个方法被调了几次、参数是什么”。它关心的是交互过程。
  • Fake(伪实现):一个有真实逻辑但简化了的实现。比如用内存里的哈希表代替真实数据库,行为上接近真实依赖,但跑得快、无副作用。

用生活化的类比:你去餐厅吃饭,Stub 是服务员告诉你“今天只有三道菜”,你点什么它都回这三道;Mock 是服务员不仅上菜,还在小本本上记下你点了几次、每次点了什么,结账时跟你核对;Fake 是这家餐厅的“模拟厨房”,真的在炒菜,只是用的是玩具灶台和塑料食材,流程完整但成本极低。

1.3 状态验证与行为验证的分野

理解这三个概念,关键在于理解两种验证思路。

状态验证关注的是“操作之后,系统的状态对不对”。比如调用addUser之后,用户列表长度是不是变成 1 了。这种验证方式天然适合配合 Stub 和 Fake,因为你只关心输入输出和最终状态,不关心中间调用了谁。

行为验证关注的是“被测代码有没有按约定跟协作者交互”。比如下单成功后,有没有调用一次库存扣减、有没有调用一次支付接口。这种验证方式必须用 Mock,因为只有 Mock 会记录调用轨迹。

很多测试写得脆,根源就是把这两种验证方式用错了地方。该用状态验证的地方硬上 Mock,结果重构一下内部实现,测试全红,但功能其实没坏。反过来,该验证交互的地方只验证了状态,漏掉了“支付接口被调了两次”这种严重 bug。这个判断标准后面会反复用到。

2. Stub 的实战用法与边界

2.1 Stub 最典型的场景:喂数据

Stub 的职责单一到不能再单一:当被测代码向某个协作者要数据时,给它一份准备好的数据。它不记录、不断言、不关心调用次数。

举个最常见的例子。假设有个订单服务,需要根据用户等级计算折扣,用户信息来自一个仓储接口:

class UserRepository: def find_by_id(self, user_id): # 真实实现会查数据库 ... class OrderService: def __init__(self, user_repo): self.user_repo = user_repo def calculate_discount(self, user_id, amount): user = self.user_repo.find_by_id(user_id) if user.level == "vip": return amount * 0.8 return amount

测试折扣逻辑时,你根本不关心用户是怎么查出来的,只关心“给定一个 VIP 用户,折扣算得对不对”。这时候写一个 Stub:

class StubUserRepository: def __init__(self, user): self.user = user def find_by_id(self, user_id): return self.user def test_vip_discount(): repo = StubUserRepository(User(level="vip")) service = OrderService(repo) assert service.calculate_discount(1, 100) == 80

这个 Stub 只有几行,返回一个写死的用户对象。测试跑起来零依赖、毫秒级完成。这就是 Stub 的价值:把“取数据”这个动作从测试里摘出去。

2.2 Stub 的边界:不要让它承担断言职责

新手常犯的一个错误是给 Stub 加计数器,然后在测试里断言“这个方法被调了一次”。一旦你这么干,它就不再是 Stub,而是 Mock 了。概念上叫什么其实无所谓,关键是你要清楚自己在做行为验证,而不是状态验证。

为什么这个区分重要?因为行为验证对实现细节的耦合度极高。上面那个例子,如果哪天重构calculate_discount,改成先查缓存、缓存没有再查仓储,那么“find_by_id 被调一次”的断言立刻失败,但业务逻辑完全正确。测试红了,你却不知道该改测试还是改代码,这就是脆测试的典型症状。

我的经验是:能用状态验证就不用行为验证。只有当“交互本身”就是需求的一部分时(比如“必须通知风控系统”),才值得上 Mock。其余情况,Stub 喂数据、断言最终结果,测试最稳。

2.3 Stub 的几种常见形态

实际项目里 Stub 不一定要手写类,很多框架提供了更轻的方式:

形态写法示例适用场景
手写类上面例子的 StubUserRepository需要复用、逻辑稍复杂
匿名对象/内联类语言支持匿名类时直接 new一次性使用
框架打桩用测试框架的 patch/mock 语法返回固定值快速替换单个方法
固定数据文件用 JSON 文件模拟接口响应接口返回结构复杂

框架打桩这块要注意一个坑:很多框架的mock方法默认行为是“返回一个万能对象,任何属性访问都返回另一个万能对象”。这种“万能 Stub”看起来很爽,实际上会掩盖问题——被测代码访问了一个根本不存在的字段,测试照样绿。我一般会显式指定返回值,宁可多写两行,也不留这种隐患。

3. Mock 的正确打开方式

3.1 Mock 解决的是“交互契约”问题

有些需求天生就是关于交互的。比如“用户注册成功后必须发送一封欢迎邮件”,这里的核心不是邮件内容(那是邮件服务自己的测试该管的),而是“注册流程有没有触发发邮件这个动作”。这种场景,状态验证无能为力,必须上 Mock。

class MockEmailSender: def __init__(self): self.sent_emails = [] def send(self, to, subject, body): self.sent_emails.append({"to": to, "subject": subject}) def test_register_sends_welcome_email(): sender = MockEmailSender() service = UserService(sender) service.register("a@example.com") assert len(sender.sent_emails) == 1 assert sender.sent_emails[0]["to"] == "a@example.com"

这个 Mock 记录了每次调用,测试最后断言调用次数和参数。注意断言的内容:只断言“发了一封、发给谁”,不断言邮件正文的具体措辞。正文措辞属于邮件服务内部的实现细节,把它写进断言,以后改文案测试就红,纯属自找麻烦。

3.2 断言粒度的拿捏

Mock 断言最容易出问题的地方是粒度。断得太细,测试和实现绑死;断得太粗,漏掉真正的 bug。我的判断标准是:断言那些“如果缺失就是 bug”的交互,忽略那些“换个方式也能达成同样效果”的交互。

举个例子,一个支付流程需要“扣款成功后更新订单状态”。这里有两个交互:调用支付网关扣款、调用订单仓储更新状态。前者必须断言(没扣款就是重大 bug),后者其实可以放宽——只要最终订单状态对了,是先更新状态再扣款还是先扣款再更新状态,属于实现自由。

具体操作上,我习惯把 Mock 的断言分成两档:

  • 强断言:调用次数精确匹配、关键参数精确匹配。用于核心业务契约。
  • 弱断言:只断言“至少调用了一次”或“参数包含某个关键字段”。用于辅助性交互。

很多测试框架支持assert_called_once_with这类精确断言,也支持assert_any_call这类宽松断言,按需选用。别图省事全用精确断言,也别图稳全用宽松断言,分档处理才是可持续的做法。

3.3 Mock 滥用的三种典型症状

写测试几年下来,我见过太多 Mock 用歪的案例,总结出三个高频症状,你可以对照自查:

症状一:测试里全是 Mock,没有一个真实对象。这种测试叫“Mock 测 Mock”,测的是你的 Mock 配置对不对,而不是业务逻辑对不对。判断方法很简单:把被测代码整个删掉,测试还能通过吗?如果能,说明测试根本没测到东西。

症状二:为了 Mock 而改生产代码。比如为了能注入 Mock,硬生生给一个纯函数加了个依赖参数。这是本末倒置。生产代码的设计应该由业务需求驱动,测试适配生产代码,而不是反过来。遇到这种情况,通常说明这个依赖点本身设计得就不合理,值得重新审视。

症状三:断言调用顺序。除非业务上明确要求“必须先 A 后 B”,否则不要断言调用顺序。顺序断言会让测试对实现细节极度敏感,重构成本飙升。我印象很深的一次,某项目测试里断言了“先查缓存再查数据库”,后来团队把缓存策略改成异步预热,测试全红,排查半天发现业务毫无问题,纯属测试写太死。

4. Fake 的价值与被低估的现实

4.1 Fake 是“能跑起来的简化版”

Fake 和前两者的本质区别在于:它有真实逻辑。Stub 是死数据,Mock 是记录器,而 Fake 是一个真正会“运算”的实现,只是把昂贵的部分换成了廉价的替代品。

最经典的 Fake 就是内存数据库。真实数据库要建连接、走网络、写磁盘,内存数据库就是一个哈希表加一些查询逻辑,接口完全一致,速度快几个数量级,测试之间还能轻松隔离(每个测试 new 一个)。

class FakeUserRepository: def __init__(self): self.users = {} self.next_id = 1 def save(self, user): user.id = self.next_id self.users[user.id] = user self.next_id += 1 return user def find_by_id(self, user_id): return self.users.get(user_id) def find_by_level(self, level): return [u for u in self.users.values() if u.level == level]

这个 Fake 实现了增、查、按条件查三个方法,逻辑简单但完整。用它跑测试,你可以验证“保存后能查出来”“按等级筛选结果正确”这类涉及多步操作的场景,这是 Stub 做不到的——Stub 只能返回预设值,没法模拟“存进去再取出来”的流程。

4.2 什么时候该用 Fake 而不是 Mock

判断标准还是回到验证类型。当你需要验证的是一串操作之后的状态,而且这串操作涉及多次读写交互,Fake 就是最佳选择。典型场景:

  • 仓储层的测试:增删改查的组合逻辑
  • 缓存逻辑的测试:写入后读取、过期后失效
  • 状态机的测试:多次状态转移后的最终状态

反过来,如果只是单次调用、只关心返回值,Stub 更轻;如果关心调用轨迹,Mock 更合适。Fake 的成本在于你要维护它的逻辑,所以只在“多次交互 + 状态验证”的场景才值得投入。

4.3 Fake 的维护成本与一致性风险

Fake 最大的坑是行为漂移。你手写的 Fake 仓储和真实仓储,随着时间推移会越走越远。真实仓储加了软删除,Fake 没加;真实仓储对空值做了特殊处理,Fake 没做。结果就是测试全绿,上线就炸。

应对办法有三条,都是实践中验证过的:

第一,给 Fake 写契约测试。同一套测试用例,既跑真实实现,也跑 Fake 实现,确保两者行为一致。这套用例通常放在集成测试里,跑真实实现;单元测试里跑 Fake。两边都绿,才敢放心用。

第二,控制 Fake 的复杂度。Fake 只实现测试真正用到的方法,用不到的方法直接抛NotImplementedError。这样一旦有人用了没实现的方法,测试立刻报错,而不是悄悄返回错误结果。

第三,优先用现成的 Fake 实现。很多生态里有成熟的 Fake,比如内存消息队列、内存文件系统、可控时钟。这些经过大量项目验证,比自己手写靠谱得多。自己写 Fake 只留给业务特有的领域对象。

5. 三者选型的决策流程

5.1 一张决策表搞定选型

概念讲完,落到实操就是选型。我整理了一张决策表,按顺序问自己几个问题,基本能定位到该用哪个:

问题是否
需要验证的是最终状态还是交互行为?状态 → 继续问交互 → 用 Mock
交互是否涉及多次读写、需要真实逻辑?是 → 用 Fake否 → 用 Stub
依赖是否慢/不稳定/有副作用?是 → 必须用替身否 → 可以考虑用真实对象
替身逻辑是否复杂到需要维护?是 → 优先找现成 Fake否 → 手写 Stub 即可

这张表的关键在于第一个问题。很多人一上来就想“我要 Mock 什么”,其实应该先问“我要验证什么”。验证目标决定了替身类型,而不是反过来。

5.2 一个完整例子串起三种替身

假设有个“下单”流程:校验库存、扣减库存、创建订单、发通知。四个协作者,正好对应三种替身的不同用法。

  • 库存查询:只需要返回“有货/没货”,用Stub喂数据。
  • 库存扣减:需要验证“确实扣了、扣了多少”,用Mock记录调用。
  • 订单创建:涉及写入后能查出来,用Fake内存仓储。
  • 通知发送:验证“发了一次”,用Mock。
def test_place_order(): stock_stub = StubStockService(available=True) stock_mock = MockStockService() order_repo = FakeOrderRepository() notifier_mock = MockNotifier() service = OrderService(stock_stub, stock_mock, order_repo, notifier_mock) service.place_order(user_id=1, sku="A100", qty=2) # 状态验证:订单确实创建了 assert order_repo.count() == 1 # 行为验证:库存扣了、通知发了 assert stock_mock.deduct_calls == [("A100", 2)] assert notifier_mock.send_count == 1

这个例子里,三种替身各司其职,测试既覆盖了状态又覆盖了关键交互,而且每个断言都有明确的业务含义。这就是选型正确的样子。

5.3 别陷入“纯度”之争

网上有不少关于“Mock 主义者 vs 古典主义者”的争论,一派主张所有协作者都 Mock,一派主张尽量用真实对象只在边界处用替身。我的看法很实际:看你的测试跑多快、有多稳。

如果项目小、依赖少,真实对象跑得动,那就少用替身,测试更接近真实行为。如果项目大、依赖多、跑一次要几分钟,那就果断上替身,把速度拉回来。纯度之争没有意义,能持续跑、跑得快的测试才是好测试。我见过坚持“零 Mock”的团队,最后因为测试太慢,CI 排队两小时,大家干脆不跑了,这比用 Mock 糟糕一百倍。

6. 实操中的高频坑与排查

6.1 时间、随机数这类隐形依赖

有一类依赖特别容易被忽略:系统时间和随机数。代码里一句datetime.now()或random(),测试就变得不可复现。今天跑绿,明天跨天了就红;这次跑绿,换个随机种子就红。

处理办法是把它们也当成依赖注入进来。时间用一个可控时钟替身,随机数用一个固定种子的生成器替身。这类替身通常用 Stub 就够了——测试需要“现在几点”,Stub 返回一个固定时间;需要“下一个随机数”,Stub 返回预设序列。

class FixedClock: def __init__(self, fixed_time): self.fixed_time = fixed_time def now(self): return self.fixed_time def test_expiry(): clock = FixedClock(datetime(2024, 1, 1, 12, 0, 0)) token = Token(clock, ttl_seconds=3600) assert token.is_expired(datetime(2024, 1, 1, 13, 0, 1)) is True

这个技巧看着简单,但能消灭一大类“偶发红”的测试。我现在的习惯是:项目一开始就把时钟和随机源抽象成接口,后面所有测试都受益。

6.2 替身和真实实现行为不一致

这是 Fake 章节提过的问题,但值得单独强调,因为它是“测试绿、线上炸”的头号元凶。排查思路是:当线上出问题但测试没抓到,第一反应应该是“我的替身是不是跟真实实现行为不一样”。

排查方法:把出问题的场景写成集成测试,跑真实依赖,看能不能复现。如果能复现而单元测试没抓到,就对比替身和真实实现在这个场景下的行为差异,补上契约测试。这个流程走一遍,通常能挖出好几个隐藏的替身缺陷。

6.3 常见问题速查表

现象可能原因处理办法
测试偶发失败,重跑就好时间/随机数/并发未隔离注入可控时钟和随机源
改内部实现测试就红行为验证过度、断言太细改用状态验证,放宽断言粒度
测试全绿但线上出 bug替身与真实实现行为漂移补契约测试,对比两者行为
测试跑得慢用了真实依赖未替换慢依赖换成 Stub 或 Fake
Mock 配置一大堆被测代码依赖过多审视设计,拆分职责
断言调用顺序频繁失败顺序断言耦合实现去掉顺序断言,只验证关键交互

6.4 几条压箱底的经验

最后分享几条不太会写在文档里、但实际很管用的经验。

替身命名要带语义。别叫mock1、stubA,叫stub_stock_available、mock_notifier。测试失败时,光看名字就知道是哪个替身的问题,排查效率差好几倍。

一个测试只验证一个关注点。既验证状态又验证一堆交互的测试,失败时很难定位。拆成多个测试,每个测试的替身配置和断言都聚焦,维护成本反而低。

替身不要跨测试复用。每个测试 new 一套替身,保证测试之间完全隔离。共享替身会引入测试顺序依赖,是偶发失败的常见来源。

定期清理没用的替身方法。Fake 里实现了但没人调用的方法,是维护负担也是误导。用不到就删,需要时再加。

测试失败先看替身配置。很多“测试挂了”其实是替身没配好,比如 Stub 返回了 None 导致空指针。养成先检查替身的习惯,能省下大量排查时间。

这套东西说到底就一句话:替身是为了让测试更快更稳更聚焦,而不是为了替身而替身。每次写替身之前问自己一句“我到底要验证什么”,答案清楚了,选型自然就对了。我在实际项目里带新人时,通常让他们先把这三种替身各手写一遍,不借助任何框架,写完再去看框架的封装,理解会深很多。框架的语法糖很甜,但底层逻辑不清楚,用起来就是照猫画虎,出了问题也不知道从哪查起。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/31 3:26:00

GitHub热点项目Hermes-Agent:AI智能体电话接入技术解析

1. GitHub热点项目解析:Hermes-Agent技术全景本周GitHub最受关注的开源项目当属NousResearch团队开发的Hermes-Agent,这个为AI智能体提供电话号码接入能力的工具库在开发者社区引发热议。作为一个长期关注AI工程化落地的技术博主,我发现该项目…

作者头像 李华
网站建设 2026/10/31 3:25:45

impeccable:AI coding agents前端设计质量校验实战

1. 从"impeccable"这个词说起:一个被低估的前端质量信号第一次看到"impeccable"作为项目名,我下意识觉得这又是一个玩概念的工具。毕竟在AI coding agents满天飞的当下,每天都有新项目冒出来,名字一个比一个唬…

作者头像 李华
网站建设 2026/10/31 3:25:36

openrig 环境编排:Claude Code 与 Codex 的 tmux 持久化部署指南

1. 从零认识 openrig:它到底解决什么问题第一次看到 openrig 这个名字,很多人会以为是某个硬件支架或者开源机械臂项目。实际上,结合它周边高频出现的关键词——Claude Code、Codex、Node.js、tmux——可以判断出,openrig 是一个面…

作者头像 李华
网站建设 2026/10/31 3:23:47

纯GDI实时抗锯齿源码:超采样与Wu直线实现绘图平滑

简介:面向 Windows GDI 绘图开发者的抗锯齿实战代码包,适合具备 Win32 与 VC 基础、需要提升界面绘制质量的中级开发者;针对使用 LineTo、Ellipse 等接口绘制斜线与曲线时出现的像素锯齿问题,给出可嵌入项目的实时优化思路与实现参…

作者头像 李华
网站建设 2026/10/31 3:23:47

Context-Mode 上下文处理模式:多轮对话与Agent编排的核心设计

1. 从“context-mode”这个词说起:它到底指什么第一次看到“context-mode”这个标题,很多人会愣一下——它不像“XX管理系统”“XX爬虫框架”那样一眼能看出用途,反而像某个库里的一个枚举值、一个配置项,或者某个编辑器里的一个状…

作者头像 李华
网站建设 2026/10/31 3:22:51

Node.js+Vue3全栈电商开发实战与优化技巧

1. 项目概述:零食电商全栈开发实战去年帮学弟调试毕业设计时,发现一个现象:90%的前端初学者在构建全栈项目时,都会卡在前后端数据联调这个环节。今天我们就以零食销售系统为例,用Node.jsVue3这个黄金组合,手…

作者头像 李华