← 返回题目列表

测试替身有哪几种?为什么说难测的代码是设计有问题?

中等 第 17 / 27 题 更新于 2026/08/03
测试替身mockstub可测性依赖注入

简化版

「测试替身(test double)」是所有替代真实依赖的对象的统称,Meszaros 把它分成五类,关键区别在「是否有行为」和「是否参与断言」① Dummy——只是填参数位、根本不会被用到(传个 None 或空对象);② Stub——提供预设的返回值,让被测代码能走下去(「查用户就返回这个假用户」);③ Spy——记录被怎么调用了,事后可以检查(「记录发了几封邮件」);④ Mock——预先设定期望,不满足就失败(「必须以这些参数调用一次」);⑤ Fake——有真实但简化的实现(内存版数据库、假的支付网关)。Python 里 unittest.mock.Mock 一个类就能扮演后四种角色,所以很多人分不清——但区分它们的实际价值在于「断言的位置」用 stub 时断言「被测代码的返回值/状态」(状态验证),用 mock 时断言「被测代码调用了什么」(行为验证)——过度使用行为验证会让测试和实现细节强耦合,重构时测试全挂但功能没坏,这是测试最常见的坏味道。更重要的一个认知是:难以测试的代码,几乎总是设计有问题——如果一个函数需要 patch 五个模块路径才能测,说明它耦合了太多具体实现;正确的解法不是「更努力地 mock」,而是把依赖变成参数(依赖注入)、把副作用推到边界、让核心逻辑成为纯函数判断标准很实用能不 mock 就不 mock,优先用真实对象;必须替换时优先用 Fake 而不是 Mock;patch 的路径越长说明设计越差。核心记忆:五种替身,区别在「有无行为」和「参不参与断言」状态验证优于行为验证难测 = 设计问题,解法是依赖注入而不是更多 mock

详细版

五种测试替身对比

类型有行为吗参与断言吗典型用途
Dummy❌ 完全不用只为填参数位
Stub✅ 返回预设值让代码能走下去
Spy✅ 真实或预设✅ 事后检查记录调用
Mock✅ 预设✅ 预先设期望验证交互
Fake✅ 简化的真实实现内存数据库
from unittest.mock import Mock, MagicMock, patch, call
import pytest

# ① ★Dummy:只填位置★
def test_dummy():
    logger = None                                  # ★根本不会被调用★
    result = process(data, logger=logger)
    assert result == expected

# ② ★★Stub:提供返回值(最常用)★★
def test_stub():
    repo = Mock()
    ★repo.get_user.return_value = User(id=1, name="alice")★   # ★预设★
    service = UserService(repo)
    result = service.get_display_name(1)
assert result == "alice"# ★★断言"结果"(状态验证)★★

# ★用 side_effect 表达序列或异常★
repo.get_user.side_effect = [user1, user2]         # ★依次返回★
repo.get_user.side_effect = NotFound("不存在")      # ★抛异常★
repo.get_user.side_effect = lambda uid: User(uid)  # ★按参数计算★

# ③ ★Spy:记录调用,事后检查★
def test_spy():
    mailer = Mock()
    service = OrderService(mailer=mailer)
    service.place_order(order)
assert mailer.send.call_count == 1
assert mailer.send.call_args.kwargs["to"] == "a@b.com"

# ④ ★★Mock:预先设期望(行为验证)★★
def test_mock():
    payment = Mock()
    service = OrderService(payment=payment)
    service.place_order(order)
    ★payment.charge.assert_called_once_with(order.id, Decimal("99.9"))★
    ★payment.refund.assert_not_called()★
# ★★断言的是"调用了什么",不是"结果是什么"★★

# ⑤ ★★Fake:简化的真实实现(★常被低估★)★★
class FakeUserRepo:
    def __init__(self):
        self._data: dict[int, User] = {}
        self._next_id = 1
    def save(self, user: User) -> User:
        if user.id is None:
            user.id = self._next_id; self._next_id += 1
        self._data[user.id] = user
        return user
    def get(self, uid: int) -> User | None:
        return self._data.get(uid)
    def find_by_email(self, email: str) -> User | None:
        return next((u for u in self._data.values() if u.email == email), None)

def test_with_fake():
    repo = FakeUserRepo()                          # ★★真实行为、无外部依赖★★
    service = UserService(repo)
    service.register("a@b.com", "pw")
assert repo.find_by_email("a@b.com") is not None# ★状态验证★
# ★ ✓ ★重构 service 内部实现时,这个测试不会挂★

# ⑥ ★★可测性设计:把依赖变成参数★★
# ✗ 难测:内部硬编码依赖
class OrderService:
    def place(self, order):
        ★db = Database(settings.DB_URL)★            # ★★硬依赖★★
        ★resp = requests.post(PAYMENT_URL, ...)★    # ★★硬依赖★★
        ★send_email(order.user.email, ...)★         # ★★硬依赖★★
# ★测试要 patch 三个模块路径,而且路径写错还不报错★

# ✓ 好测:依赖注入
class OrderService:
    def __init__(self, repo, payment, mailer, clock=datetime.now):
        self.repo, self.payment = repo, payment
        self.mailer, self.clock = mailer, clock
    def place(self, order): ...
# ★测试:OrderService(FakeRepo(), FakePayment(), FakeMailer(), lambda: FIXED_TIME)★
# ★★不用 patch 任何东西★★

# ⑦ ★★纯函数 + 边界隔离(最好的可测性)★★
# ★核心逻辑:纯函数(极易测试)★
def calculate_discount(items: list[Item], coupon: Coupon | None,
                       member_level: int) -> Decimal:
    ...                                            # ★无 IO、无副作用★
# ★边界:薄薄一层,只做 IO★
def place_order_handler(req):
    items = repo.get_items(req.item_ids)           # ★IO★
    discount = ★calculate_discount(items, ...)★     # ★★纯计算★★
    repo.save(Order(...))                          # ★IO★
# ★→ 90% 的业务逻辑测试都不需要任何替身★

⚠️ 三个必须记住的点:① 五种替身的实际差别在「断言什么」。用 stub 时你断言的是被测代码的返回值或最终状态(「给它一个返回 alice 的假 repo,它应该返回 ‘alice’」)——这叫状态验证;用 mock 时你断言的是被测代码对协作者的调用(「它必须调用 payment.charge(order_id, 99.9)」)——这叫行为验证状态验证优于行为验证:前者只关心「做对了没有」,实现怎么变都不影响;后者把实现细节写进了测试,一旦重构(哪怕功能完全没变)测试就会大面积失败——「重构时测试全挂但功能没坏」是行为验证过度的典型症状。只有当「调用本身就是要验证的业务需求」时才用行为验证(比如「下单后必须发一封确认邮件」)。② Fake 被严重低估Mock() 写起来最快,但它没有任何真实行为——repo.save(user) 之后 repo.get(user.id) 返回的还是个 Mock 对象而不是刚保存的用户,所以你只能验证「调用了 save」而不能验证「数据真的存进去了」。Fake 是「简化但行为正确的实现」(内存字典版的 repository、内存版的消息队列),它让你能写状态验证的测试,而且一次编写、所有测试复用。③ 难以测试的代码是设计问题的信号,不是「测试太难写」的借口。如果一个函数需要 patch 五个模块路径、需要 mock 的对象要设置十几个属性、或者你发现自己在 mock 自己写的私有方法——这都在说明它耦合了太多具体实现、承担了太多职责。正确的反应不是「更努力地 mock」,而是改设计:把依赖变成构造参数、把副作用推到调用边界、把核心逻辑抽成不碰 IO 的纯函数。

完整版教学

一、五种替身的准确区分

★ ★Meszaros 的分类(xUnit Test Patterns)★:
  ┌────────────────────────────────────────────────────────┐
  │ ★Dummy★  :只为满足参数签名,★永远不会被真正使用★         │
  │ ★Stub★   :★提供预设的输入★,让被测代码能继续执行          │
  │ ★Spy★    :Stub + ★记录被如何调用★(事后检查)             │
  │ ★Mock★   :★预先声明期望★,不满足就让测试失败              │
  │ ★Fake★   :★有可工作的简化实现★(但不适合生产)            │
  └────────────────────────────────────────────────────────┘

★ ★两个关键维度★:
             ★提供行为?★        ★参与断言?★
  Dummy         ✗                  ✗
  Stub          ✓(预设值)         ✗
  Spy           ✓                  ✓(★事后★)
  Mock          ✓(预设值)         ✓(★事先★)
  Fake          ✓(★真实逻辑★)     ✗

★ ★Spy 和 Mock 的细微差别★:
  # ★Spy:先跑,后检查★
  mailer = Mock()
  service.place_order(o)
  ★assert mailer.send.called★              # ★事后断言★
  # ★Mock:先声明期望(严格 mock 库里)★
  mailer = ★create_autospec(Mailer)★
  # 有些框架里可以 mailer.expects("send").with_args(...)
  ★ ★Python 的 unittest.mock 更偏 Spy 风格★
    (先执行,再 assert_called_with)
  ★ ★所以日常讨论里 mock 和 spy 常被混用★,重点是理解★断言的位置★

★ ★Fake 为什么特殊★:
  ★ 它是★唯一有真实逻辑★的替身
  ★ 例子:
    - ★内存版 Repository★(用 dict 实现 save/get/find)
    - ★SQLite 代替 PostgreSQL★(★但注意行为差异★)
    - ★内存版消息队列★(list 实现 publish/consume)
    - ★假的时钟★(可控制推进)
    - ★假的支付网关★(按金额返回不同结果)
  ★ ✓ ★能做状态验证★(save 之后真的能 get 出来)
  ★ ✓ ★重构被测代码时测试不会挂★
  ★ ✗ ★要维护★(但通常几十行)
  ★ ✗ ★可能和真实实现行为不一致★ → 用★契约测试★保证

★ ★契约测试:保证 Fake 和真实实现行为一致★:
  # ★同一套测试跑在两个实现上★
  @pytest.fixture(params=["fake", "real"])
  def repo(request, db):
      return FakeUserRepo() if request.param == "fake" else SqlUserRepo(db)

  def test_save_then_get(repo):                # ★★两个实现都要满足★★
      u = repo.save(User(email="a@b.com"))
      assert repo.get(u.id).email == "a@b.com"
  def test_find_by_email_missing(repo):
      assert repo.find_by_email("none@x.com") is None
  ★ ✓ ★Fake 偏离真实行为时立刻发现★
  ★ ★这是 Fake 方案的关键配套★

★ ★Python 里的对应工具★:
  Dummy  → None / object() / Mock()
  Stub   → ★Mock(return_value=...)★ / 手写小类
  Spy    → ★Mock() + assert_called_*★
  Mock   → ★create_autospec() + assert_called_once_with★
  Fake   → ★手写类★ / fakeredis / moto(假 AWS)/ 内存 SQLite

五种替身的两个关键维度是「提供行为吗」和「参与断言吗」Spy 和 Mock 的差别在断言时机(Spy 是事后检查、Mock 是事先声明期望),而 Python 的 unittest.mock 更偏 Spy 风格(先执行再 assert_called_with),所以日常讨论中两者常被混用——重点是理解「断言的位置」而不是纠结术语Fake 是唯一有真实逻辑的替身,好处是能做状态验证、重构时测试不会挂,代价是要维护、且可能和真实实现行为不一致——配套手段是契约测试:用参数化 fixture 让同一套测试同时跑在 Fake 和真实实现上,Fake 一旦偏离就立刻发现

二、状态验证 vs 行为验证

★ ★同一个需求的两种测法★:
  # 需求:下单后要扣库存

  # ★① 行为验证(mock)★
  def test_place_order_behavior():
      inventory = Mock()
      service = OrderService(inventory)
      service.place(order)
      ★inventory.deduct.assert_called_once_with(order.sku, order.qty)★
  ★ ✓ 不需要 inventory 有真实实现
  ★ ✗ ★把"怎么做"写进了测试★
     → 如果重构成 inventory.reserve() + inventory.commit()
     → ★功能完全正确,但测试挂了★

  # ★② 状态验证(fake)★
  def test_place_order_state():
      inventory = FakeInventory({sku: 10})
      service = OrderService(inventory)
      service.place(order)                      # qty=3
      ★assert inventory.stock_of(sku) == 7★     # ★★断言结果★★
  ★ ✓ ★只关心"结果对不对",实现怎么变都行★
  ★ ✓ ★重构友好★

★ ★★什么时候必须用行为验证★★:
  ① ★调用本身就是需求★
     "下单后必须发一封确认邮件" → ★邮件发出去了是业务要求★
     mailer.send.assert_called_once()
  ② ★没有可观察的状态★
     "记录审计日志" → 日志系统在外部,只能验证"调用了"
  ③ ★验证"不该调用"★
     "余额不足时不能扣款" → ★payment.charge.assert_not_called()★
  ④ ★验证调用顺序/次数★
     "重试三次" → assert client.call_count == 3
  ⑤ ★外部副作用无法在测试中观察★
     发短信、推送、调第三方 API

★ ★★行为验证过度的症状★★:
  ✗ ★测试里 mock 了自己写的私有方法★
    @patch.object(service, "_calculate_price")   # ★★在测试实现细节★★
  ✗ ★assert 的调用参数是内部数据结构★
  ✗ ★重构(不改行为)导致大量测试失败★
  ✗ ★测试代码比被测代码还长★
  ✗ ★读测试看不出"这个功能是干什么的",只看到一堆 assert_called★

★ ★"测试应该验证行为,不是实现"的实际含义★:
  ★行为★ = ★从外部可观察的效果★
    - 返回值
    - ★状态变化(数据库、内存)★
    - ★对外部系统的调用(发邮件、扣款)★  ← ★这也是行为★
  ★实现★ = ★内部怎么做到的★
    - 调用了哪些私有方法
    - 用了什么中间数据结构
    - 内部调用的顺序
  ★ ★所以"验证发了邮件"是行为验证但是对的;
    "验证调用了 _build_email_body()"是实现验证,是错的★

★ ★经验法则★:
  ┌────────────────────────────────────────────────┐
  │ ★能断言返回值/状态 → 优先断言它★                 │
  │ ★只有"对外部的副作用"才用行为验证★               │
  │ ★永远不要 mock 被测类自己的方法★                 │
  │ ★永远不要断言私有方法的调用★                     │
  └────────────────────────────────────────────────┘

同一个需求可以用行为验证或状态验证来测,而后者通常更好。行为验证 inventory.deduct.assert_called_once_with(...) 把「怎么做」写进了测试——如果重构成 reserve() + commit()功能完全正确但测试挂了;状态验证 assert inventory.stock_of(sku) == 7 只关心结果,实现怎么变都行但有五种情况必须用行为验证调用本身就是需求(「下单后必须发确认邮件」)、没有可观察的状态(审计日志)、验证「不该调用」(余额不足时不能扣款)、验证次数或顺序(重试三次)、以及外部副作用无法观察。关键是分清「行为」和「实现」「验证发了邮件」是行为验证、是对的;「验证调用了 _build_email_body()」是实现验证、是错的——永远不要 mock 被测类自己的方法、不要断言私有方法的调用

三、可测性设计

★ ★★"难测"的三个信号★★:
  ① ★要 patch 很多路径★
     @patch("app.service.requests.post")
     @patch("app.service.Database")
     @patch("app.service.send_email")
     @patch("app.service.datetime")            # ★★四个补丁 = 设计有问题★★
     def test_x(m1, m2, m3, m4): ...
  ② ★mock 对象要设置一大堆属性才能跑通★
     m.return_value.__enter__.return_value.execute.return_value.fetchall...
     ★ ★这种"火车残骸"说明违反了迪米特法则★
  ③ ★测试代码比被测代码长好几倍★

★ ★根因:耦合了"具体实现"而不是"抽象"★
  ✗ class OrderService:
        def place(self, order):
            db = ★Database(settings.DB_URL)★     # ★依赖具体类 + 全局配置★
            requests.post(★PAYMENT_URL★, ...)    # ★依赖具体库 + 全局常量★
            ★send_email(...)★                    # ★依赖全局函数★
            if ★datetime.now()★ > deadline: ...  # ★依赖全局时钟★

★ ★★解法一:依赖注入(最基础也最有效)★★:
  class OrderService:
      def __init__(self, repo: OrderRepo, payment: PaymentGateway,
                   mailer: Mailer, clock: Callable[[], datetime] = ...):
          ...
  ★ ✓ ★测试时传 Fake,不用 patch★
  ★ ✓ ★依赖一目了然(构造函数就是"我需要什么"的清单)★
  ★ ✓ ★参数多了本身就是"职责过多"的信号★
  ★ 形式:构造注入(推荐)/ 方法参数注入 / 属性注入

★ ★★解法二:核心逻辑做成纯函数★★:
  # ★把"计算"和"IO"分开★
  # ✗ 混在一起
  def process_order(order_id):
      order = db.get(order_id)                  # IO
      total = sum(i.price * i.qty for i in order.items)   # 计算
      if order.coupon: total -= order.coupon.value        # 计算
      db.save(order, total)                     # IO

  # ✓ ★分离★
  def ★calculate_total★(items, coupon) -> Decimal:        # ★★纯函数★★
      total = sum(i.price * i.qty for i in items)
      return total - coupon.value if coupon else total
  def process_order(order_id):                  # ★薄边界★
      order = db.get(order_id)
      total = calculate_total(order.items, order.coupon)
      db.save(order, total)
  ★ ✓ ★calculate_total 的测试:零替身、毫秒级、覆盖所有边界★
  ★ ✓ ★这是"函数式核心 + 命令式外壳"模式★
  ★ ★经验:业务规则越复杂,这个分离的收益越大★

★ ★解法三:面向接口(Protocol)★
  from typing import Protocol
  class Mailer(Protocol):
      def send(self, to: str, subject: str, body: str) -> None: ...
  # ★真实实现★
  class SmtpMailer:
      def send(self, to, subject, body): ...
  # ★测试实现★
  class FakeMailer:
      def __init__(self): self.sent = []
      def send(self, to, subject, body):
          self.sent.append((to, subject, body))
  ★ ✓ ★Protocol 是结构化子类型,不需要显式继承★
  ★ ✓ ★类型检查器能验证 Fake 实现完整★

★ ★解法四:把副作用推到边界★
  ┌────────────────────────────────────────────────┐
  │ ★外层(薄)★:IO、框架、外部服务                  │
  │   ↓ 传入数据                                     │
  │ ★内层(厚)★:★纯业务逻辑,无副作用★              │
  │   ↑ 返回"要做什么"的描述                          │
  │ ★外层执行★                                      │
  └────────────────────────────────────────────────┘
  # ★极致形态:返回"意图"而不是执行★
  def decide_actions(order, inventory) -> list[Action]:   # ★纯函数★
      actions = []
      if inventory.can_fulfill(order):
          actions.append(DeductStock(order.sku, order.qty))
          actions.append(SendEmail(order.user.email, "已下单"))
      else:
          actions.append(SendEmail(order.user.email, "缺货"))
      return actions
  # ★测试:断言返回的 action 列表(纯粹的状态验证)★
  ★ ✓ ★复杂决策逻辑可以完全脱离 IO 测试★

★ ★什么时候可以不做这些★:
  ✓ ★脚本、一次性工具★
  ✓ ★逻辑极简的 CRUD★(直接集成测试更划算)
  ✗ ★核心业务规则、算法、状态机 → 一定要可测★

「难测」的三个信号:要 patch 很多路径、mock 对象要设置一堆嵌套属性(「火车残骸」说明违反了迪米特法则)、测试代码比被测代码长好几倍。根因是耦合了「具体实现」而不是「抽象」。四个解法:依赖注入(最基础也最有效,构造函数就是「我需要什么」的清单,参数多了本身就是职责过多的信号);核心逻辑做成纯函数「函数式核心 + 命令式外壳」模式,纯函数的测试零替身、毫秒级);面向 Protocol 编程(结构化子类型,不需要显式继承,类型检查器还能验证 Fake 实现完整);把副作用推到边界(极致形态是让纯函数返回「要做什么」的 Action 列表,外层负责执行——复杂决策逻辑就能完全脱离 IO 测试)。

四、Python mock 的实践要点

★ ★★patch 的路径规则(最常错的地方)★★:
  # myapp/service.py
  ★from myapp.utils import send_email★
  def notify(user):
      send_email(user.email, "hi")

  # ✗ 错误:patch 定义处
  @patch("myapp.utils.send_email")            # ★★不生效!★★
  # ✓ 正确:patch ★使用处★
  @patch("★myapp.service★.send_email")
  ★ ★规则:patch 的是"名字在哪个命名空间被查找"★
  ★ ✗ ★路径写错时不会报错,只是没生效★ → ★★测试假绿★★
  ✓ ★用 create=False(默认)+ autospec 能减少这类错误★

★ ★★autospec:防止 mock 掩盖签名错误★★:
  # ✗ 普通 Mock:任何调用都接受
  m = Mock()
  m.send(wrong_arg, another=123)              # ★★不报错★★
  # → ★被测代码把参数写错了,测试照样通过★

  # ✓ autospec:按真实签名校验
  ★m = create_autospec(Mailer)★
  ★m.send(wrong_arg)★                          # ★★TypeError★★
  # 或
  ★@patch("app.service.Mailer", autospec=True)★
  ★ ✓ ★真实类改了签名,测试会失败(这是好事)★
  ★ ★强烈建议默认加 autospec★

★ ★Mock vs MagicMock★:
  Mock()       # 基础
  MagicMock()  # ★额外支持魔术方法(__len__ / __iter__ / __enter__ 等)★
  ★ ★patch 默认用 MagicMock★
  ★ 需要 mock 上下文管理器:
    m.__enter__.return_value = fake_conn       # MagicMock 才有

★ ★常用断言★:
  m.assert_called()                    # 至少调用一次
  ★m.assert_called_once()★             # 恰好一次
  ★m.assert_called_with(a, b=1)★       # ★最后一次调用的参数★
  ★m.assert_called_once_with(...)★     # ★恰好一次且参数匹配★
  ★m.assert_not_called()★
  m.assert_any_call(...)               # 任意一次匹配
  ★m.assert_has_calls([call(1), call(2)], any_order=False)★
  m.call_count / m.call_args / m.call_args_list
  ★m.call_args.args / m.call_args.kwargs★      # ★3.8+ 更清晰★

★ ★★重置与隔离★★:
  m.reset_mock()                       # 清空调用记录
  ★m.reset_mock(return_value=True, side_effect=True)★
  ★ ✓ ★用 pytest fixture 每个测试新建 Mock,别复用全局的★

★ ★patch 的四种用法★:
  # ① 装饰器(★注意参数顺序:从下往上★)
  @patch("app.b")
  @patch("app.a")
  def test(mock_a, mock_b): ...        # ★★a 在前★★
  # ② 上下文管理器(★推荐,作用域明确★)
  with patch("app.a") as mock_a: ...
  # ③ 手动 start/stop
  p = patch("app.a"); mock_a = p.start(); ...; p.stop()
  # ④ ★★pytest 的 monkeypatch(自动还原)★★
  def test(monkeypatch):
      monkeypatch.setattr("app.a", fake)
      monkeypatch.setenv("KEY", "v")
      monkeypatch.delenv("OTHER", raising=False)

★ ★mock 的常见坑★:
  ✗ ★patch 路径写错 → 静默不生效 → 测试假绿★
  ✗ ★忘了 autospec → 签名错误被掩盖★
  ✗ ★mock 返回 Mock 而不是真实类型 → 后续代码行为诡异★
    ✓ 明确设置 return_value 的类型
  ✗ ★mock 太深(m.a.b.c.d)→ 说明违反迪米特法则★
  ✗ ★patch 了第三方库的内部实现 → 库升级就挂★
    ✓ ★只 patch 自己代码的边界★

patch 的路径规则是最常错的地方要 patch「名字被查找的地方」而不是「定义的地方」——from myapp.utils import send_email 之后,要 patch 的是 myapp.service.send_email路径写错时不会报错,只是没生效,测试假绿强烈建议默认加 autospec=True——普通 Mock 接受任何调用,被测代码把参数写错了测试照样通过,而 autospec 会按真实签名校验。patch 的四种用法里推荐上下文管理器(作用域明确)或 pytest 的 monkeypatch(自动还原)。几个常见坑:路径写错静默失效、忘了 autospec、mock 太深(m.a.b.c.d)说明违反迪米特法则、以及patch 第三方库的内部实现(库升级就挂)——只应该 patch 自己代码的边界

五、什么时候不该用替身

★ ★★优先级:能不用就不用★★
  ┌────────────────────────────────────────────────────┐
  │ ① ★真实对象★(纯函数、值对象、简单的领域对象)        │
  │ ② ★Fake★(内存实现)                                │
  │ ③ ★Stub★(预设返回值)                              │
  │ ④ ★Mock/Spy★(★只在验证副作用时★)                   │
  └────────────────────────────────────────────────────┘

★ ★不该 mock 的东西★:
  ✗ ★值对象 / dataclass / 纯函数★
    → ★直接构造真实的,更简单也更准确★
    ✗ mock_user = Mock(); mock_user.name = "alice"
    ✓ user = User(id=1, name="alice")
  ✗ ★被测类自己的方法★
    → ★在测试实现细节★
  ✗ ★标准库的基础设施★(list、dict、str)
  ✗ ★第三方库的内部实现★
    → ★只 mock 你调用它的那一层(自己的适配器)★
  ✗ ★数据库(如果能用事务回滚或内存库)★
    → ★真实数据库测试能发现 SQL 错误、约束冲突★

★ ★★"不要 mock 你不拥有的类型"★★:
  ✗ @patch("stripe.Charge.create")             # ★第三方 API 的内部★
  ✓ ★包一层自己的适配器,然后替换适配器★:
    class PaymentGateway(Protocol):
        def charge(self, amount: Decimal, token: str) -> ChargeResult: ...
    class StripeGateway:                       # ★真实实现(★单独做契约测试★)★
        def charge(self, amount, token): return stripe.Charge.create(...)
    class FakeGateway:                         # ★测试用★
        def charge(self, amount, token): return ChargeResult(ok=True)
  ★ ✓ ★第三方 API 变了只改一处★
  ★ ✓ ★测试不依赖第三方库的实现细节★

★ ★数据库该不该 mock★:
  ✗ ★mock ORM 的查询链★
    session.query.return_value.filter.return_value.first.return_value = user
    → ★★这个测试什么都没验证★★(SQL 写错了它照样过)
  ✓ ★用真实数据库 + 事务回滚★(推荐)
  ✓ ★或用 Repository 模式 + Fake 实现★
  ★ ★原则:数据访问层用真实数据库测,业务逻辑层用 Fake repo 测★

★ ★过度 mock 的代价(★真实案例形态★)★:
  测试:mock 了 repo、mock 了 service、mock 了 validator
  → ★所有测试都通过★
  → ★但把 repo 的一个方法名改了,忘了改 service★
  → ★★测试全绿,线上炸了★★
  ★ ★因为 mock 隔离掉了"组件之间是否真的能协作"★
  ✓ ★配套:少量的集成测试验证真实协作★
  ✓ ★或用 autospec(改签名时测试会挂)★

★ ★测试金字塔视角★:
  ┌──────────────────────────────────────────────┐
  │ ★E2E(少)★         :★零替身,真实全链路★      │
  │ ★集成测试(适量)★  :★真实数据库,替换外部服务★│
  │ ★单元测试(多)★    :★Fake/Stub,纯函数最优★   │
  └──────────────────────────────────────────────┘
  ★ ★替身用得越多,越要有上层测试兜底★

替身的优先级是「真实对象 > Fake > Stub > Mock」——能不用就不用不该 mock 的东西:值对象和纯函数(直接构造真实的更简单)、被测类自己的方法、标准库基础设施、第三方库的内部实现、以及能用事务回滚的数据库。「不要 mock 你不拥有的类型」是重要原则——不要直接 patch stripe.Charge.create,而应该包一层自己的适配器再替换它,这样第三方 API 变了只改一处。mock ORM 的查询链是典型的无效测试session.query.return_value.filter.return_value...)——SQL 写错了它照样通过过度 mock 最大的代价是「隔离掉了组件之间能否真的协作」:改了 repo 的方法名忘了改 service,测试全绿但线上炸了——配套手段是少量集成测试加上 autospec

六、实践清单

★ ★选择替身的决策树★:
  需要替换这个依赖吗?
   ├─ ★不需要(纯函数/值对象/快且无副作用)★ → ★用真实的★
   └─ 需要(慢/有副作用/不确定/难构造)
       ├─ ★要验证"结果状态"★ → ★Fake★
       ├─ ★只需要让代码跑下去★ → ★Stub★
       └─ ★要验证"是否调用了外部副作用"★ → ★Mock/Spy★

★ 检查清单:
  【替身选择】
  □ ★优先用真实对象,其次 Fake★
  □ ★不 mock 值对象和纯函数★
  □ ★不 mock 被测类自己的方法★
  □ ★不 mock 第三方库内部(包适配器)★
  □ ★数据库优先用真实的 + 事务回滚★
  【mock 用法】
  □ ★patch 使用处而不是定义处★
  □ ★默认加 autospec=True★
  □ ★用 monkeypatch fixture(自动还原)★
  □ ★每个测试新建 Mock,不复用全局的★
  □ ★mock 层级不超过 2 层(m.a.b)★
  【设计】
  □ ★依赖通过构造函数注入★
  □ ★核心逻辑是纯函数★
  □ ★副作用推到边界★
  □ ★面向 Protocol 而不是具体类★
  □ ★要 patch 3 个以上路径 → 重构信号★
  【断言】
  □ ★优先断言返回值/状态★
  □ ★只对外部副作用做行为验证★
  □ ★Fake 配契约测试保证与真实实现一致★

★ ★重构可测性的顺序★:
  ① ★先加集成测试兜底★(保证重构不改变行为)
  ② ★把依赖提到构造函数★
  ③ ★抽出纯函数★
  ④ ★用 Fake 替换 Mock★
  ⑤ ★删掉那些验证实现细节的断言★

★ ★一个改造前后的对比★:
  # ★改造前:4 个 patch,测试 40 行★
  @patch("app.svc.datetime")
  @patch("app.svc.requests")
  @patch("app.svc.Database")
  @patch("app.svc.send_email")
  def test_place_order(m_mail, m_db, m_req, m_dt):
      m_dt.now.return_value = FIXED
      m_db.return_value.query.return_value... # ★★火车残骸★★
      ...

  # ★改造后:零 patch,测试 8 行★
  def test_place_order():
      svc = OrderService(FakeRepo(), FakePayment(),
                         FakeMailer(), clock=lambda: FIXED)
      svc.place(order)
      ★assert svc.repo.get(order.id).status == "paid"★
      ★assert len(svc.mailer.sent) == 1★

★ 一句话总结:
  ★"五种替身的区别在『有无真实行为』和『参不参与断言』——
    Dummy 填位、Stub 给返回值、Spy 记录调用、Mock 预设期望、
    Fake 有简化实现;状态验证优于行为验证(后者把实现细节
    写进测试,重构就挂);难测的代码是设计问题,
    解法是依赖注入 + 纯函数 + 副作用推到边界,而不是更努力地 mock。"★

检查清单分四块。重构可测性有个安全的顺序先加集成测试兜底(保证重构不改变行为)→ 依赖提到构造函数 → 抽出纯函数 → 用 Fake 替换 Mock → 删掉验证实现细节的断言。最后那个对比很能说明问题:同一个测试从「4 个 patch、40 行、满是火车残骸」变成「零 patch、8 行、清晰的状态断言」——变的不是测试技巧,而是被测代码的设计

记忆钩子:「★测试替身(test double)是所有替代真实依赖的对象的统称★,Meszaros 分五类,★两个关键维度是『有无真实行为』和『参不参与断言』★:★Dummy 只填参数位永远不会被用★、★Stub 提供预设返回值让代码能走下去★、★Spy 记录被怎么调用了(事后检查)★、★Mock 预先设定期望(不满足就失败)★、★Fake 有简化但可工作的真实实现★。★Python 的 unittest.mock 一个类就能扮演后四种,所以术语常被混用——重点是理解『断言的位置』★。★核心判断:状态验证优于行为验证★——用 stub 时断言★被测代码的返回值或最终状态★,用 mock 时断言★被测代码调用了什么★;★后者把实现细节写进了测试,重构时功能没坏但测试全挂★,这是最常见的测试坏味道。★但五种情况必须用行为验证★:调用本身就是需求(下单必发邮件)、没有可观察状态(审计日志)、★验证『不该调用』(余额不足不能扣款)★、验证次数顺序、外部副作用无法观察。★关键是分清『行为』和『实现』:验证发了邮件是对的,验证调用了 _build_email_body() 是错的★——★永远不要 mock 被测类自己的方法★。★Fake 被严重低估★:Mock 没有任何真实行为(save 之后 get 出来的还是 Mock),★Fake 是内存字典版的实现,能做状态验证且重构时不会挂★,配套要做★契约测试(用参数化 fixture 让同一套测试跑在 Fake 和真实实现上)★保证行为一致。★更重要的认知:难以测试的代码几乎总是设计有问题★——★三个信号是『要 patch 三个以上路径』『mock 要设置一堆嵌套属性(火车残骸,违反迪米特法则)』『测试比被测代码长好几倍』★;★正确的反应不是更努力地 mock,而是改设计★:★依赖注入(构造函数就是『我需要什么』的清单,参数多了本身就是职责过多的信号)★、★核心逻辑抽成纯函数(函数式核心+命令式外壳)★、★副作用推到边界(极致形态是纯函数返回 Action 列表,外层执行)★、★面向 Protocol 而不是具体类★。mock 的实践要点:★patch 使用处而不是定义处(路径写错不报错只是没生效=测试假绿)★、★默认加 autospec=True(普通 Mock 接受任何调用,参数写错也不报)★、用 monkeypatch fixture 自动还原。★不要 mock 你不拥有的类型★——包一层自己的适配器再替换;★mock ORM 查询链是典型的无效测试(SQL 写错照样过)★;★过度 mock 最大的代价是隔离掉了『组件之间能否真的协作』,改了方法名忘了同步,测试全绿但线上炸★。」

七、常见误区与追问

  • 误区:Mock、Stub、Spy 都是同一个东西的不同叫法。 术语确实常被混用(尤其在 Python 里,unittest.mock.Mock 一个类就能扮演所有角色),但区分它们的实际价值在于「你的测试在断言什么」。用 Stub 时,你给它一个预设返回值,然后断言被测代码的输出或状态——测试关心的是「给定这个输入,结果对不对」;用 Mock/Spy 时,你断言被测代码对协作者的调用——测试关心的是「它有没有按预期的方式使唤别人」。这个区别决定了测试的脆弱程度:前者只要功能正确就通过,实现随便怎么重构;后者把「调用了哪个方法、传了什么参数」固化进了测试,任何内部重构都可能让它失败,哪怕行为完全没变。所以实践中的建议是:默认用 stub + 状态断言,只在「验证对外部世界的副作用」时才用行为断言
  • 误区:难测的代码就多写点 mock,测试总能写出来。 能写出来不代表有价值。当一个函数需要 patch 四五个模块路径、mock 对象要设置 m.return_value.__enter__.return_value.execute.return_value.fetchall.return_value 这样的嵌套属性时,这个测试的问题是:① 它没有验证什么——所有依赖都被替换成了你自己编造的行为,测试通过只能说明「在我假设的世界里代码能跑」;② 它极其脆弱——被测代码任何一点结构调整都要同步改测试;③ 它掩盖了真正的问题——那么多外部依赖挤在一个函数里,本身就意味着这个函数承担了太多职责。「难以测试」是设计给你的反馈信号,正确的反应是重构:把依赖变成构造参数、把纯计算抽成独立函数、把 IO 推到调用边界。改造之后你会发现测试从 40 行的 mock 堆砌变成 8 行的清晰断言——变的不是测试技巧,是被测代码的设计
  • 误区:@patch("myapp.utils.send_email") 应该 patch 函数定义的地方。 要 patch 的是「名字被查找的地方」。Python 的 from X import Y 会在当前模块的命名空间里创建一个指向该对象的新绑定——所以 myapp/service.py 里写了 from myapp.utils import send_email 之后,service 模块里有一个自己的 send_email 名字;你去替换 myapp.utils.send_email 只是改了原模块的绑定,service 模块里的那个引用还指向原来的函数,patch 完全不生效。正确的是 @patch("myapp.service.send_email")最坑的是路径写错时不会报任何错patch 只要能找到那个属性就成功),测试照常通过——但它验证的是真实函数的行为,可能真的发了邮件,这就是「假绿」。两个防范手段:import myapp.utils 然后调用 myapp.utils.send_email(...)(这样 patch 定义处就生效了,因为查找是运行时通过模块对象进行的);以及autospec=True,它会在 patch 时校验目标存在且签名匹配。
  • 误区:用 Mock() 替换 repository 就能测业务逻辑了。 能测,但测不到有价值的东西Mock() 没有任何真实行为——repo.save(user) 之后再 repo.get(user.id),返回的是一个全新的 Mock 对象而不是刚保存的那个用户。所以你的测试只能写成 repo.save.assert_called_once_with(user)(行为验证),无法验证「数据真的被正确地存进去了」。更糟的是,如果被测代码里有「先 save 再 get 出来做二次处理」的逻辑,Mock 返回的假对象会让代码走进完全不真实的路径。Fake 解决了这个问题:一个用 dict 实现的 FakeUserRepo(通常几十行)具备真实的存取语义,你就能写 repo.save(u); assert repo.get(u.id).email == "a@b.com" 这样的状态验证——测试更接近真实、重构时也不会挂。代价是要维护 Fake 并保证它和真实实现行为一致,配套手段是契约测试:用参数化 fixture 让同一套测试同时跑在 Fake 和真实 repo 上。
  • 误区:mock 得越彻底,单元测试越纯粹,质量越高。 过度 mock 会让测试失去发现真实缺陷的能力。有个典型的失败模式:service 层测试 mock 了 repo、repo 层测试 mock 了 session、controller 测试 mock 了 service——每一层的测试都是绿的,但没有任何一个测试验证过「这些层真的能拼在一起工作」。于是当你把 repo.find_by_email 改名成 repo.get_by_email 却漏改了 service 里的调用时,所有单元测试照样全绿(因为 Mock 接受任何方法调用),线上一跑就 AttributeError。两个应对:① 用 autospec=True——它会按真实类的签名创建 mock,方法不存在或参数不匹配时立刻报错,能挡住这类问题;② 保留适量的集成测试——用真实的组件(真实数据库 + 真实 repo + 真实 service)验证关键路径,替身用得越多,越需要上层测试兜底。这也是测试金字塔的意义:底层大量的快速单元测试 + 中层适量的集成测试 + 顶层少量的端到端测试。
  • 追问:什么时候该用 Fake,什么时候用 Stub 就够了? 判断标准是**「测试需不需要验证状态变化」。如果测试只是「让被测代码能走下去」——比如「给它一个返回已存在用户的 repo,验证它会抛出 EmailAlreadyExists」——那么一个 Mock(return_value=existing_user) 的 stub 就完全够了,写起来最快。但如果测试要验证多步操作后的最终状态**——「注册用户后,再查询应该能找到;重复注册应该失败;删除后再查应该为空」——stub 就无能为力了(它不记住任何东西),必须用 Fake。另一个考虑维度是复用频率:如果十几个测试都需要一个能存取的 repo,写一次 Fake(几十行)比在每个测试里配置 stub 的返回值划算得多,而且测试的可读性好很多FakeRepo() 比五行 mock.xxx.return_value = ... 清晰)。经验做法:项目里为每个核心的仓储/网关接口维护一个 Fake,简单的一次性替换用 stub
  • 追问:「不要 mock 你不拥有的类型」具体该怎么做? 这条原则说的是:不要直接 patch 第三方库的内部函数或类(比如 @patch("stripe.Charge.create")@patch("boto3.client"))。原因有三:① 脆弱——第三方库升级改了内部结构,你的测试就挂了,而你的业务代码可能完全没问题;② 需要了解实现细节——你得知道 stripe 内部是怎么调用的才能正确 mock,这些知识很快会过期;③ 无法验证你对它的用法是否正确——mock 掉之后,参数传错、API 用法过时都发现不了。正确做法是在第三方库外面包一层自己的适配器PaymentGateway 协议 + StripeGateway 实现),业务代码只依赖这个协议;测试时替换成 FakeGateway(你自己拥有的类型)。这样带来三个好处:① 第三方 API 变化只影响适配器一个文件② 业务逻辑测试完全不接触第三方库③ 适配器本身可以用少量的契约测试或真实沙箱环境验证——把「和第三方交互是否正确」这个风险集中到一处,而不是散布在所有测试里。
  • 追问:核心逻辑抽成纯函数具体怎么做? 关键动作是把「决策」和「执行」分开。典型的混杂代码是:查数据库 → 算折扣 → 判断库存 → 扣库存 → 发邮件 → 写日志,全在一个函数里。改造分两步。第一步是把纯计算提出来calculate_discount(items, coupon, member_level) -> Decimal 这样的函数没有任何 IO、给定输入必得同样输出——它的测试可以覆盖所有边界情况(零元、满减叠加、过期券、会员等级组合),不需要任何替身、毫秒级运行、并且永远不会 flaky第二步(进阶)是让决策逻辑返回「意图」而不是直接执行decide_actions(order, inventory) -> list[Action] 返回 [DeductStock(...), SendEmail(...)] 这样的描述对象,由外层薄薄的一层负责真正执行。这样复杂的业务决策(什么情况下该做什么)完全可以脱离 IO 测试——断言返回的 Action 列表就行。这个模式叫「函数式核心 + 命令式外壳」,业务规则越复杂收益越大。要注意的是:不必对所有代码都这么做——简单的 CRUD 直接写集成测试更划算,这个模式的价值在于「有复杂业务规则」的地方。

八、加强记忆

测试替身(test double)是所有替代真实依赖的对象的统称,Meszaros 分成五类,两个关键维度是「有无真实行为」和「参不参与断言」Dummy 只填参数位、永远不会被用到Stub 提供预设返回值、让代码能走下去Spy 记录被怎么调用了(事后检查)Mock 预先设定期望(不满足就失败)Fake 有简化但可工作的真实实现Python 的 unittest.mock 一个类就能扮演后四种角色,所以术语常被混用——重点是理解「断言的位置」而不是纠结名词核心判断是:状态验证优于行为验证——用 stub 时断言被测代码的返回值或最终状态,用 mock 时断言被测代码调用了什么后者把实现细节写进了测试,重构时功能没坏但测试全挂,这是最常见的测试坏味道。但有五种情况必须用行为验证:调用本身就是需求(下单必发邮件)、没有可观察的状态(审计日志)、验证「不该调用」(余额不足不能扣款)、验证次数或顺序、外部副作用无法观察。关键是分清「行为」和「实现」:验证发了邮件是对的,验证调用了 _build_email_body() 是错的——永远不要 mock 被测类自己的方法Fake 被严重低估:Mock 没有任何真实行为(save 之后 get 出来的还是个 Mock),而 Fake 是内存字典版的实现,能做状态验证且重构时不会挂,配套要做契约测试(用参数化 fixture 让同一套测试跑在 Fake 和真实实现上)保证两者行为一致。更重要的认知是:难以测试的代码几乎总是设计有问题——三个信号是「要 patch 三个以上路径」「mock 要设置一堆嵌套属性(火车残骸,违反迪米特法则)」「测试比被测代码长好几倍」正确的反应不是更努力地 mock,而是改设计依赖注入(构造函数就是「我需要什么」的清单,参数多了本身就是职责过多的信号)、核心逻辑抽成纯函数(函数式核心 + 命令式外壳)、副作用推到边界(极致形态是纯函数返回 Action 列表、外层负责执行)、面向 Protocol 而不是具体类。mock 的实践要点:patch 使用处而不是定义处(路径写错不报错、只是没生效 = 测试假绿)、默认加 autospec=True(普通 Mock 接受任何调用,参数写错也不报)、用 monkeypatch fixture 自动还原。「不要 mock 你不拥有的类型」——包一层自己的适配器再替换;mock ORM 查询链是典型的无效测试(SQL 写错照样通过);过度 mock 最大的代价是隔离掉了「组件之间能否真的协作」,改了方法名忘了同步,测试全绿但线上炸。