4. 测试替身与依赖隔离

在真实后端系统中,一个业务函数往往同时依赖数据库、缓存、消息队列、支付网关、短信服务或第三方 HTTP API。如果每次单元测试都去连接真实依赖,测试会变得慢、脆弱、难以复现,甚至产生脏数据或真实扣费。测试替身(Test Double)的核心目标,就是用可控对象替换真实协作者,让测试只关注当前被测逻辑。常见的替身包括 stub、fake、mock 和 monkeypatch,它们解决的问题不同,不能混为一谈。

四类替身的职责边界

Stub(桩)只负责“给返回值”。例如被测代码调用 get_user(user_id),stub 直接返回一个固定字典,不关心调用次数和参数。它适合验证“当依赖返回某种数据时,业务逻辑是否正确”。

Fake(伪实现)是一个可工作的简化实现。例如用内存字典模拟用户表,支持 save、get、delete,行为接近真实数据库,但不落盘、不联网。Fake 适合需要多次读写、状态变化的场景,比 stub 更真实,比真实数据库更轻。

Mock(模拟对象)不仅提供返回值,还记录“是否被调用、调用几次、参数是什么”。它适合验证交互行为,例如“支付成功后必须调用一次 send_notification”。但 mock 过度使用会让测试与实现细节耦合,重构时容易大面积失败。

Monkeypatch(猴子补丁)是 pytest 提供的运行时替换机制,可在测试期间临时修改模块属性、环境变量、字典项或类方法,测试结束后自动恢复。它适合替换模块级函数、配置项或全局客户端,而不需要改动生产代码。

测试替身替换真实依赖的隔离结构 被测代码只依赖抽象接口,真实数据库 / 外部 API / 第三方服务在测试中被替身替换 被测业务函数 create_order(user_id, amount) 校验 → 扣款 → 写单 → 通知 只依赖接口,不关心实现 依赖接口(抽象边界) UserRepo / PaymentGateway Notifier / CacheClient 生产实现与测试替身可互换 真实依赖(生产环境) PostgreSQL / Redis 支付网关 / 短信 API 慢、有副作用、难复现 生产绑定 测试替身层(测试期间替换接口实现) Stub 桩 固定返回值 get_user → {"id":1} 不校验调用次数 适合:分支逻辑验证 Fake 伪实现 内存字典模拟存储 save / get / delete 有状态、可多次读写 适合:仓储层测试 Mock 模拟对象 记录调用与参数 assert_called_once_with 验证交互行为 适合:通知/扣款调用 Monkeypatch 运行时临时替换 模块属性/环境变量 测试后自动恢复 适合:全局客户端/配置 测试时注入替身

用 pytest 与 unittest.mock 落地

假设有一个订单服务,依赖 UserRepo、PaymentGateway 和 Notifier。生产代码通过构造函数注入依赖,测试时传入替身即可,无需 monkeypatch 全局对象。

class OrderService:
    def __init__(self, user_repo, payment, notifier):
        self.user_repo = user_repo
        self.payment = payment
        self.notifier = notifier

    def create_order(self, user_id, amount):
        user = self.user_repo.get(user_id)
        if user is None:
            raise ValueError("user not found")
        if amount <= 0:
            raise ValueError("invalid amount")
        tx_id = self.payment.charge(user_id, amount)
        self.notifier.send(user_id, f"order paid: {tx_id}")
        return {"user_id": user_id, "amount": amount, "tx_id": tx_id}

下面用 unittest.mock.Mock 构造替身。注意 payment.charge 需要返回一个字符串,因此用 return_value 指定;notifier.send 不需要返回值,但需要验证是否被调用。

from unittest.mock import Mock
import pytest
from order_service import OrderService

def test_create_order_success():
    user_repo = Mock()
    user_repo.get.return_value = {"id": 7, "name": "alice"}

    payment = Mock()
    payment.charge.return_value = "tx_20240501_001"

    notifier = Mock()

    service = OrderService(user_repo, payment, notifier)
    result = service.create_order(7, 99.5)

    assert result["tx_id"] == "tx_20240501_001"
    user_repo.get.assert_called_once_with(7)
    payment.charge.assert_called_once_with(7, 99.5)
    notifier.send.assert_called_once_with(7, "order paid: tx_20240501_001")

这个测试不连接数据库、不调用支付网关、不发短信,运行时间在毫秒级。它同时验证了返回值、依赖调用次数和调用参数。如果业务逻辑改成“金额小于 1 元不通知”,只需调整 notifier.send 的断言,而不需要改真实服务。

Fake 处理有状态依赖

当被测逻辑需要多次读写同一份数据时,Mock 的固定返回值就不够用了。例如测试“创建订单后能查询到订单”,可以用一个内存 Fake 仓储:

class FakeOrderRepo:
    def __init__(self):
        self._data = {}
        self._seq = 0

    def save(self, order):
        self._seq += 1
        order_id = f"o_{self._seq}"
        self._data[order_id] = {**order, "id": order_id}
        return order_id

    def get(self, order_id):
        return self._data.get(order_id)

def test_save_then_get():
    repo = FakeOrderRepo()
    oid = repo.save({"user_id": 7, "amount": 99.5})
    assert repo.get(oid)["amount"] == 99.5
    assert repo.get("not_exist") is None

Fake 的行为接近真实仓储,但完全在内存中运行,且每次测试都从空状态开始,避免测试间互相污染。它比 Mock 更适合验证“写入后读取”“重复保存”“删除后查询”等状态迁移。

Monkeypatch 替换模块级依赖

有些依赖不是通过构造函数注入的,而是模块级全局对象,例如 requests.get、redis_client 或环境变量。pytest 的 monkeypatch fixture 可以在测试期间临时替换它们,并在测试结束后自动还原。

import requests
from weather_service import fetch_weather

def test_fetch_weather(monkeypatch):
    class FakeResp:
        status_code = 200
        def json(self):
            return {"city": "shanghai", "temp": 26}

    def fake_get(url, timeout=3):
        assert "weather" in url
        return FakeResp()

    monkeypatch.setattr(requests, "get", fake_get)
    data = fetch_weather("shanghai")
    assert data["temp"] == 26

这里用 monkeypatch.setattr 把 requests.get 替换成 fake_get,测试结束后 pytest 自动恢复原函数。它适合替换第三方库入口、环境变量(monkeypatch.setenv)和配置字典项(monkeypatch.setitem)。

选择替身的经验规则:只关心返回值用 Stub;需要状态变化用 Fake;需要验证调用行为用 Mock;需要临时替换模块级对象或环境变量用 Monkeypatch。优先通过构造函数注入依赖,monkeypatch 作为补充手段,避免测试与全局状态耦合。
常见误区:用 Mock 替代一切,导致测试只验证“调用了什么”,却不验证“结果是否正确”;或者 Fake 实现与真实实现行为偏差过大,测试通过但生产失败。Fake 应尽量保持与真实接口一致的输入输出契约,并在集成测试中用真实依赖再验证一次。

隔离边界与测试分层

测试替身不是越多越好。单元测试应隔离数据库、外部 API、第三方服务,保证快速和稳定;集成测试则应使用真实数据库或容器化依赖,验证替身与真实实现之间的契约是否一致。一个实用的分层策略是:单元测试用 Stub/Fake/Mock 覆盖分支和交互;契约测试用 Fake 与真实实现跑同一组用例;端到端测试只保留少量关键路径,连接真实依赖。

最后,替身应尽量贴近真实接口的签名和异常行为。例如真实 payment.charge 在余额不足时抛出 PaymentError,Mock 也应通过 side_effect=PaymentError("insufficient") 模拟异常,而不是只返回成功值。只有让替身在正常与异常路径上都可信,依赖隔离才能真正提升测试质量,而不是制造虚假的绿色通过。