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 提供的运行时替换机制,可在测试期间临时修改模块属性、环境变量、字典项或类方法,测试结束后自动恢复。它适合替换模块级函数、配置项或全局客户端,而不需要改动生产代码。
用 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)。
隔离边界与测试分层
测试替身不是越多越好。单元测试应隔离数据库、外部 API、第三方服务,保证快速和稳定;集成测试则应使用真实数据库或容器化依赖,验证替身与真实实现之间的契约是否一致。一个实用的分层策略是:单元测试用 Stub/Fake/Mock 覆盖分支和交互;契约测试用 Fake 与真实实现跑同一组用例;端到端测试只保留少量关键路径,连接真实依赖。
最后,替身应尽量贴近真实接口的签名和异常行为。例如真实 payment.charge 在余额不足时抛出 PaymentError,Mock 也应通过 side_effect=PaymentError("insufficient") 模拟异常,而不是只返回成功值。只有让替身在正常与异常路径上都可信,依赖隔离才能真正提升测试质量,而不是制造虚假的绿色通过。