星点匠心建站 星点匠心建站XD JIANXIN STUDIO
400-883-9527 免费定制方案
ARTICLE DETAIL

资讯详情

深耕新锐建站与运营推广的一线实战洞察。

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

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 用户折扣算得对不对”。这时候写一个 Stubclass 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(levelvip)) 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(aexample.com) assert len(sender.sent_emails) 1 assert sender.sent_emails[0][to] aexample.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(availableTrue) stock_mock MockStockService() order_repo FakeOrderRepository() notifier_mock MockNotifier() service OrderService(stock_stub, stock_mock, order_repo, notifier_mock) service.place_order(user_id1, skuA100, qty2) # 状态验证订单确实创建了 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_seconds3600) assert token.is_expired(datetime(2024, 1, 1, 13, 0, 1)) is True这个技巧看着简单但能消灭一大类“偶发红”的测试。我现在的习惯是项目一开始就把时钟和随机源抽象成接口后面所有测试都受益。6.2 替身和真实实现行为不一致这是 Fake 章节提过的问题但值得单独强调因为它是“测试绿、线上炸”的头号元凶。排查思路是当线上出问题但测试没抓到第一反应应该是“我的替身是不是跟真实实现行为不一样”。排查方法把出问题的场景写成集成测试跑真实依赖看能不能复现。如果能复现而单元测试没抓到就对比替身和真实实现在这个场景下的行为差异补上契约测试。这个流程走一遍通常能挖出好几个隐藏的替身缺陷。6.3 常见问题速查表现象可能原因处理办法测试偶发失败重跑就好时间/随机数/并发未隔离注入可控时钟和随机源改内部实现测试就红行为验证过度、断言太细改用状态验证放宽断言粒度测试全绿但线上出 bug替身与真实实现行为漂移补契约测试对比两者行为测试跑得慢用了真实依赖未替换慢依赖换成 Stub 或 FakeMock 配置一大堆被测代码依赖过多审视设计拆分职责断言调用顺序频繁失败顺序断言耦合实现去掉顺序断言只验证关键交互6.4 几条压箱底的经验最后分享几条不太会写在文档里、但实际很管用的经验。替身命名要带语义。别叫mock1、stubA叫stub_stock_available、mock_notifier。测试失败时光看名字就知道是哪个替身的问题排查效率差好几倍。一个测试只验证一个关注点。既验证状态又验证一堆交互的测试失败时很难定位。拆成多个测试每个测试的替身配置和断言都聚焦维护成本反而低。替身不要跨测试复用。每个测试 new 一套替身保证测试之间完全隔离。共享替身会引入测试顺序依赖是偶发失败的常见来源。定期清理没用的替身方法。Fake 里实现了但没人调用的方法是维护负担也是误导。用不到就删需要时再加。测试失败先看替身配置。很多“测试挂了”其实是替身没配好比如 Stub 返回了 None 导致空指针。养成先检查替身的习惯能省下大量排查时间。这套东西说到底就一句话替身是为了让测试更快更稳更聚焦而不是为了替身而替身。每次写替身之前问自己一句“我到底要验证什么”答案清楚了选型自然就对了。我在实际项目里带新人时通常让他们先把这三种替身各手写一遍不借助任何框架写完再去看框架的封装理解会深很多。框架的语法糖很甜但底层逻辑不清楚用起来就是照猫画虎出了问题也不知道从哪查起。
返回列表