常青翻新|Mock 不是万能替身:Dummy/Stub/Spy/Mock/Fake 五种 Test Double 的边界与踩坑

举报
霍格沃兹测试学社 发表于 2026/09/18 19:16:11 2026/09/18
【摘要】 本文厘清测试替身的五大分类(Dummy/Stub/Spy/Mock/Fake),指出滥用“万能Mock”导致测试脆弱或假绿的根源:混淆“提供数据”与“验证交互”。强调选型核心原则——**先明确断言对象(数据?交互?真实行为?),再决定替身类型**,并结合AI场景(如Agent轨迹验证、假模型替代)赋予经典模式新生命力。

一段很常见的 pytest 代码:测试一个下单服务,作者写下 mock_db = MagicMock(),然后既让它 return_value 返回一条订单,又在结尾 mock_db.save.assert_called_once() 断言它被调了一次。测试过了。可过两周,有人给下单流程加了一步「保存前先校验库存」,多调了一次 db,这条 assert_called_once 就莫名其妙红了——而红的原因和「下单对不对」毫无关系。

同一份代码里,还藏着另一个更隐蔽的问题:那个 MagicMock 到底是在提供数据,还是在验证交互?作者自己大概也说不清,反正「Mock 嘛,万能」。

这就是把所有测试替身都笼统叫「Mock」的代价:测试要么脆得一碰就红,要么假绿——看着断言了一堆,其实什么都没真正验证到。Gerard Meszaros 在《xUnit Test Patterns》里早就把测试替身分成了五种:Dummy、Stub、Spy、Mock、Fake。它们各有明确的用途和断言对象,混用就是 bug 的温床。今天这篇常青翻新,把这五分法按 AI 时代的语境重讲一遍。

一、先把五个名字分清:断言对象决定用哪种替身

先给结论:选哪种替身,不取决于「被替换的东西是什么」,而取决于「你这条测试到底要断言什么」。

  • Dummy(哑对象):只是为了填满参数列表,测试里根本不用它。断言对象——无。
  • Stub(桩):提供预设的返回值,把「被测对象依赖的数据」喂成固定的。断言对象——被测对象拿到这些数据后的状态/返回值
  • Spy(间谍):记录自己被怎么调用了(调了几次、什么参数),但不改变行为。断言对象——调用发生过没有
  • Mock(模拟):预设「期望的交互」,测试结束时验证这些交互是否如约发生。断言对象——交互本身
  • Fake(伪实现):一个真的能跑、但轻量的实现,比如内存数据库。断言对象——被测对象在真实行为下的状态。

一句话记法:Stub 管「输入数据」,Mock/Spy 管「交互行为」,Fake 管「真实但轻量的实现」,Dummy 管「凑数」。 开头那段代码的病根,就是把「提供订单数据」(该用 Stub)和「验证 save 被调一次」(Mock 交互断言)揉进了同一个 MagicMock,结果一条本该只验数据的测试,被交互断言拖累得极脆。

二、Stub:只提供数据,别顺手断言交互

Stub 的职责单一——被测对象问它要什么,它就返回预设的什么,测试只断言被测对象自己的输出。

from unittest.mock import MagicMock

def test_order_total_uses_discount():
    # Stub:只负责返回固定的折扣率,不关心它被调了几次
    pricing = MagicMock()
    pricing.get_discount.return_value = 0.1     # 提供数据

    from service import calc_total
    total = calc_total(pricing, base=100)        # 100 * (1 - 0.1)

    assert total == 90                           # 断言被测对象的返回值,不断言 pricing 的调用

为什么这么写、踩过什么坑。 这里刻意没有pricing.get_discount.assert_called_once()。Stub 的价值就在于稳定:只要它返回 0.1,calc_total 该算出 90 就算出 90,至于内部调了一次还是两次折扣查询,那是实现细节,不该被这条测试锁死。最常见的坑就是给 Stub 顺手加交互断言——一旦实现重构(比如加了缓存、少调一次),测试就假红。记住:你断言的是返回值,就别再断言调用次数。

三、Spy:记录调用序列,AI 场景里它对应 Trajectory

Spy 记录「被怎么调用了」,但把行为留给真实实现或默认值。它在 AI 测试里格外重要——当被测对象是 Agent,你想验证它「按什么顺序调了哪些工具」,用的就是 Spy 的思路。

from unittest.mock import MagicMock

def test_agent_calls_tools_in_order():
    tools = MagicMock()
    tools.query_order.return_value = {"status": "paid"}
    tools.do_refund.return_value = {"ok": True}

    from agent import run_refund_agent
    run_refund_agent(tools, order_id="A1001")

    # Spy 式断言:记录并校验调用序列(呼应 Trajectory 断言)
    called = [c[0] for c in tools.method_calls]
    assert called == ["query_order", "do_refund"], f"工具调用顺序不对:{called}"

为什么这么写、踩过什么坑。 Spy 断言的是「调用发生过、且顺序对」,这正是本日旗舰那篇讲的 Trajectory 断言的最小雏形——把 Agent 的工具调用序列记录下来做校验。坑在于用 Spy 去顶替本该用 Fake 的场景:比如你想测「订单存进库再查出来对不对」,用 Spy 记录 save 被调了没有任何意义,因为你根本没验证数据真的存进去了——这时候要的是 Fake。Spy 只回答「调没调、怎么调」,不回答「结果对不对」。

四、Mock:断言交互,且只在交互本身是契约时才用

Mock 预设期望的交互并在结尾验证。它适合的场景很窄:当「有没有发出这个交互」本身就是被测契约时——比如「必须发一条审计日志」「必须调用一次风控」。

from unittest.mock import MagicMock

def test_payment_triggers_audit_log():
    auditor = MagicMock()               # Mock:交互本身就是契约
    from service import process_payment
    process_payment(auditor, amount=100)

    # 断言「审计日志被以正确参数记了一次」——这就是本测试要验的契约
    auditor.log.assert_called_once_with(event="payment", amount=100)

为什么这么写、踩过什么坑。 这里用 Mock 是对的,因为「支付必须留审计痕迹」是一条真实契约,交互本身就是被测目标。但 Mock 是最容易被滥用的替身:很多人把每个依赖都换成 Mock、给每条测试堆一摞 assert_called_with,结果测试全在验证「内部怎么调的」,没有一条验证「对外产出对不对」。这种测试改一次实现就红一片,是脆弱测试的头号来源。判断标准很简单:如果这个交互不是对外契约、只是实现细节,就别用 Mock 断言它。

五、Fake:真能跑的轻量实现,AI 场景里替掉不确定的模型

Fake 是一个真实但轻量的实现,最经典的就是内存数据库。它不记录交互、不预设返回,而是真跑一遍逻辑,让被测对象在接近真实的行为下被验证。

class InMemoryOrderRepo:
    """Fake:内存版订单仓储,真存真取,替代真实数据库"""
    def __init__(self):
        self._data = {}
    def save(self, order):
        self._data[order["id"]] = order
    def get(self, order_id):
        return self._data.get(order_id)

def test_order_roundtrip():
    repo = InMemoryOrderRepo()          # Fake
    from service import place_order
    place_order(repo, {"id": "A1", "sku": "X"})
    assert repo.get("A1")["sku"] == "X"  # 断言真实存取后的状态

为什么这么写、踩过什么坑。 Fake 的价值在于「真的存进去、真的查出来」,验证的是端到端的状态正确,而不是某个方法被调了。在 AI 场景里,Fake 有一个特别贴切的用法:把不确定的大模型调用替成一个行为确定的假模型。 真实模型每次输出都可能不同,测试没法稳定断言;写一个 Fake LLM——按输入规则返回固定响应——就能让 Agent 测试变得可复现。这比用 Stub 硬塞一个 return_value 更强,因为 Fake 能模拟「不同输入给不同输出」的真实决策逻辑,让被测 Agent 的多步流程真正跑起来。

五点五、一个 code review 就能用的识别口诀

五分法讲完,落到日常最实用的其实是一条 review heuristic:看一条测试的断言写在谁身上。 断言写在被测对象的返回值/状态上,那它用的多半是 Stub 或 Fake,健康;断言写在一堆替身的 assert_called_with 上、被测对象自己的产出反而没验,那这条测试大概率被 Mock 滥用了,是脆弱测试的高危信号。再补两条快速判据:其一,一条测试里最好只有一种「主替身」承担核心职责,如果你发现同一个 MagicMock 既在 return_value 提供数据、又在结尾被断言调用次数,基本就是开头那段代码的病——把它拆成一个 Stub 加一个 Spy,各自职责单一。其二,替身能不能被轻松替换,取决于被测代码有没有把依赖作为参数传进来(依赖注入);如果依赖是在函数内部 new 出来或全局 import 死的,你连替身都塞不进去,只能靠 patch 打补丁,测试自然又脆又难读。所以「替身用不对」往往不只是测试的问题,它会在 review 里反向暴露出被测代码耦合过紧的设计问题——这也是把五分法讲清楚的另一层价值。

六、五种替身一张表:用途、断言对象、典型误用、AI 场景

把五分法汇总成一张对照表,选型时对着查即可:

替身 用途 断言对象 典型误用 AI 场景对应做法
Dummy 凑满参数,不使用 给 Dummy 加断言 占位传入不相关的上下文对象
Stub 提供固定返回值 被测对象的返回值/状态 顺手断言调用次数 把模型调用 Stub 成固定响应
Spy 记录调用序列 交互是否发生、顺序 用 Spy 顶替该用 Fake 的存取验证 记录 Agent 工具调用做 Trajectory 断言
Mock 验证期望交互 交互本身(参数、次数) 把每个依赖都 Mock、堆一摞脆弱断言 断言「必须触发风控/审计」这类契约
Fake 真实但轻量的实现 端到端状态正确性 用 Stub 塞死返回值代替真跑逻辑 写一个按规则决策的假模型/内存向量库

这张表的核心不是背下五个名字,而是那条选型主线:先问「我这条测试要断言什么」,再倒推该用哪种替身。 断言数据用 Stub,断言交互用 Mock/Spy,断言真实行为用 Fake,凑数用 Dummy。把这条主线立起来,开头那种「一个 MagicMock 既提供数据又断言交互」的混用就不会再出现。

写在最后

「Mock 万能」是个流传很广的误解,它让无数测试要么脆得一碰就红、要么假绿得什么都没验。五种 Test Double 的分界从来清晰:断言对象是数据、是交互、还是真实行为,决定了你该伸手拿哪一种。

到了 AI 时代,这套老分类不但没过时,反而更重要——被测对象从确定代码变成了不确定的模型和 Agent,你更需要用 Stub 把模型固定住、用 Spy 把工具调用序列记下来、用 Fake 造一个行为可复现的假模型。替身多替了一层,但选型的那条主线一点没变。

别问「这个依赖该不该 Mock」,先问「我这条测试到底要断言什么」——答案会自己告诉你该用哪种替身。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。