Python 基础体系 · 第 68/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。
Python unittest 与 Mock:替身边界、Patch、异步和脆弱测试
单元测试的难点通常不是“如何断言一个返回值”,而是如何确定测试边界:哪些代码应该真实执行,哪些依赖应该被替换,测试究竟要验证业务行为,还是验证某个内部调用细节。
unittest 提供测试用例、夹具、断言、测试发现和运行器;unittest.mock 则提供 Mock、MagicMock、AsyncMock、patch() 等工具,用于替换被测代码依赖并记录交互。Python 3.14 的 unittest 还支持默认彩色输出,并改进了命名空间包作为测试发现起点时的行为。(docs.python.org)
本文围绕四个问题展开:
- 测试替身应该放在哪条边界上;
patch()到底替换了什么,以及为什么经常“打了补丁但没有生效”;- 同步代码、异步代码和异步资源的测试有何不同;
- 为什么有些测试虽然全是绿色,却极其脆弱。
一、先建立测试边界:被测对象与协作者
1.1 什么是被测对象
被测对象,通常称为 SUT,即 System Under Test,是本次测试真正要验证的代码。
例如:
# app/order_service.py
class OrderService:
def __init__(self, payment_gateway, order_repo):
self.payment_gateway = payment_gateway
self.order_repo = order_repo
def place_order(self, user_id: int, amount: int) -> str:
if amount <= 0:
raise ValueError("amount must be positive")
payment_id = self.payment_gateway.charge(user_id, amount)
self.order_repo.save(user_id, amount, payment_id)
return payment_id
在测试 OrderService.place_order() 时:
OrderService.place_order()是被测对象;payment_gateway是协作者;order_repo是协作者;- “金额必须为正数”是业务规则;
- “支付成功后保存订单”是与协作者之间的交互协议。
测试边界可以形式化为:
其中:
Input是测试输入;SUT Logic是被测对象真正执行的逻辑;Collaborator Behavior是协作者在测试中提供的响应;Test Result是返回值、异常或可观察副作用。
如果测试目标是验证 OrderService 的业务逻辑,那么支付平台的真实 HTTP 请求不应成为该测试的必要条件。支付平台本身需要通过集成测试或契约测试验证,而不是在每个订单服务单元测试中重复验证。
1.2 什么是测试替身
测试替身,即 test double,是测试中用于代替真实协作者的对象。它不是某个具体类,而是一组测试角色的总称。
常见替身包括:
| 类型 | 主要作用 | 是否记录调用 |
|---|---|---|
| Dummy | 仅用于填充参数,测试不关心它 | 通常不关心 |
| Stub | 为调用提供预先设定的结果 | 通常不关心 |
| Fake | 一个可运行但简化的真实实现 | 不一定 |
| Spy | 记录调用,测试之后检查 | 是 |
| Mock | 预先配置行为,并验证交互 | 是 |
unittest.mock.Mock 可以同时承担 Stub、Spy 和 Mock 的角色:
from unittest.mock import Mock
gateway = Mock()
gateway.charge.return_value = "pay-001"
payment_id = gateway.charge(7, 100)
assert payment_id == "pay-001"
gateway.charge.assert_called_once_with(7, 100)
这里发生了两件事:
return_value让gateway.charge()返回固定结果;assert_called_once_with()验证调用次数和参数。
Mock 会在访问属性或方法时创建子 Mock,并记录这些调用;side_effect 可用于抛出异常、根据参数动态返回结果,或按顺序提供多个结果。(docs.python.org)
1.3 Mock 边界应该放在哪里
一个实用判断是:
如果协作者的真实行为不是当前测试要证明的事实,就可以考虑替换它。
例如,对于订单服务:
import unittest
from unittest.mock import Mock
class OrderService:
def __init__(self, payment_gateway, order_repo):
self.payment_gateway = payment_gateway
self.order_repo = order_repo
def place_order(self, user_id: int, amount: int) -> str:
if amount <= 0:
raise ValueError("amount must be positive")
payment_id = self.payment_gateway.charge(user_id, amount)
self.order_repo.save(user_id, amount, payment_id)
return payment_id
class TestOrderService(unittest.TestCase):
def test_place_order_charges_then_saves_order(self):
gateway = Mock()
gateway.charge.return_value = "pay-001"
repo = Mock()
service = OrderService(gateway, repo)
result = service.place_order(user_id=7, amount=100)
self.assertEqual(result, "pay-001")
gateway.charge.assert_called_once_with(7, 100)
repo.save.assert_called_once_with(7, 100, "pay-001")
测试中真实执行的是:
OrderService.place_order
├── 参数校验
├── 调用 payment_gateway.charge
├── 接收 payment_id
├── 调用 order_repo.save
└── 返回 payment_id
而不真实执行的是:
真实支付服务
真实数据库
网络连接
重试、超时、认证和序列化
这使测试快速且确定,但也产生一个边界:该测试无法证明真实支付 API 的 URL、鉴权、JSON 格式和数据库约束是否正确。这些事实必须由更高层测试覆盖。
二、行为断言与状态断言
2.1 两类可观察结果
一个测试通常观察两类结果:
其中:
- :返回结果或异常;
- :与协作者之间的交互记录。
例如:
result = service.place_order(7, 100)
可以断言:
self.assertEqual(result, "pay-001")
这是对 的断言。
也可以断言:
gateway.charge.assert_called_once_with(7, 100)
repo.save.assert_called_once_with(7, 100, "pay-001")
这是对 的断言。
关键在于:交互断言不是越多越好。它应该描述业务协议,而不是描述实现中的每一个局部动作。
2.2 失败路径必须显式建模
订单金额非法时,支付网关和仓储都不应被调用:
class TestOrderService(unittest.TestCase):
def test_rejects_non_positive_amount(self):
gateway = Mock()
repo = Mock()
service = OrderService(gateway, repo)
with self.assertRaisesRegex(ValueError, "positive"):
service.place_order(user_id=7, amount=0)
gateway.charge.assert_not_called()
repo.save.assert_not_called()
这里的因果关系是:
- 输入
amount=0; - 被测对象在调用协作者之前完成校验;
- 校验失败并抛出
ValueError; - 因为控制流提前结束,支付和保存都不会发生。
如果只写:
with self.assertRaises(ValueError):
service.place_order(7, 0)
测试只证明了异常发生,没有证明“失败发生在外部副作用之前”。
2.3 side_effect 的三种语义
side_effect 不是单一功能,它有三种常用形式。
形式一:抛出异常
gateway.charge.side_effect = TimeoutError("payment timeout")
with self.assertRaisesRegex(TimeoutError, "timeout"):
service.place_order(7, 100)
repo.save.assert_not_called()
此时调用 gateway.charge() 会直接抛出异常,后续 repo.save() 不执行。
形式二:根据参数计算结果
def charge(user_id, amount):
if amount > 1000:
raise ValueError("limit exceeded")
return f"pay-{user_id}-{amount}"
gateway.charge.side_effect = charge
self.assertEqual(
gateway.charge(7, 100),
"pay-7-100",
)
这种方式适合表达多个输入分支,但如果 side_effect 中复制了大量生产逻辑,测试就开始重新实现被测系统,容易出现“测试和生产代码同时犯同一个错误”。
形式三:按调用顺序返回结果
gateway.charge.side_effect = [
TimeoutError("temporary failure"),
"pay-001",
]
第一次调用抛出异常,第二次调用返回 "pay-001"。这种写法适合测试重试逻辑:
class RetryingOrderService:
def __init__(self, gateway):
self.gateway = gateway
def charge(self, user_id, amount):
for attempt in range(2):
try:
return self.gateway.charge(user_id, amount)
except TimeoutError:
if attempt == 1:
raise
raise AssertionError("unreachable")
测试:
class TestRetryingOrderService(unittest.TestCase):
def test_retries_after_timeout(self):
gateway = Mock()
gateway.charge.side_effect = [
TimeoutError("temporary failure"),
"pay-001",
]
service = RetryingOrderService(gateway)
self.assertEqual(service.charge(7, 100), "pay-001")
self.assertEqual(
gateway.charge.call_args_list,
[
unittest.mock.call(7, 100),
unittest.mock.call(7, 100),
],
)
side_effect 使用可迭代对象时,每次调用消耗一个元素;元素耗尽后,再次调用会失败。因此测试中应明确调用次数,否则新增一次重试可能导致错误类型发生变化。(docs.python.org)
三、Mock 的接口边界:spec、spec_set 与 autospec
3.1 无约束 Mock 的风险
默认 Mock 会动态创建任意属性:
gateway = Mock()
gateway.chargee.return_value = "pay-001"
如果生产代码实际调用的是 charge(),那么 chargee() 只是测试中拼写错误的方法。由于默认 Mock 不会阻止这个属性,测试可能仍然通过。
更糟糕的是,默认返回值也是一个 Mock:
gateway = Mock()
result = gateway.charge(7, 100)
print(result)
# <Mock name='mock.charge()' ...>
如果测试只检查“结果不为空”,这个错误可能被隐藏。
3.2 spec:限制访问接口
class PaymentGateway:
def charge(self, user_id: int, amount: int) -> str:
raise NotImplementedError
gateway = Mock(spec=PaymentGateway)
gateway.charge.return_value = "pay-001"
gateway.refund.return_value = "refund-001"
上例中,refund 不存在于 PaymentGateway,访问它会抛出 AttributeError。
gateway.refund
# AttributeError
spec 主要限制“能访问哪些属性”。它并不完整限制所有赋值行为。
3.3 spec_set:同时限制读取和设置
gateway = Mock(spec_set=PaymentGateway)
gateway.charge.return_value = "pay-001"
gateway.typo = 1
# AttributeError
spec_set 比 spec 更严格:不仅不能读取不存在的属性,也不能给不存在的属性赋值。官方文档将其定义为 spec 的严格变体。(docs.python.org)
3.4 autospec:进一步约束调用签名
仅仅限制方法名还不够。下面的调用可能仍然通过:
gateway = Mock(spec=PaymentGateway)
gateway.charge("only-one-argument")
spec 能知道存在 charge,但不一定像真实函数一样检查调用签名。
使用 autospec:
from unittest.mock import create_autospec
gateway = create_autospec(PaymentGateway, instance=True)
gateway.charge(7, 100)
gateway.charge("only-one-argument")
# TypeError
autospec 会复制被替换对象的属性、方法和调用签名;函数、方法和构造器的参数形式也会参与检查。(docs.python.org)
在 patch() 中可以这样使用:
@patch("app.order_service.PaymentGateway", autospec=True)
def test_gateway(mock_gateway_class):
gateway = mock_gateway_class.return_value
gateway.charge.return_value = "pay-001"
接口约束的目标不是让测试变得更严格,而是让错误尽可能接近真实代码暴露:
其中近似关系至少应覆盖:
- 属性名称;
- 方法名称;
- 是否可调用;
- 参数数量;
- 参数名称;
- 异步方法与同步方法的区别。
四、patch() 的核心:替换名称绑定,而不是修改“原对象”
4.1 patch() 做了什么
patch() 的目标形式通常是:
"模块路径.属性名"
例如:
@patch("app.order_service.PaymentGateway")
def test_something(mock_gateway):
...
它的生命周期是:
- 导入目标模块;
- 找到模块中的目标属性;
- 保存原属性;
- 将属性替换为新对象或自动创建的 Mock;
- 执行测试;
- 测试结束后恢复原属性。
patch() 可以作为装饰器、类装饰器或上下文管理器使用;作用域结束后会撤销替换。默认情况下,patch() 创建的是 MagicMock,也可以通过 new_callable 指定其他 Mock 类型。(docs.python.org)
4.2 最重要的规则:Patch 使用处,而不是定义处
假设项目结构如下:
# app/payment.py
class PaymentGateway:
def charge(self, user_id, amount):
return "real-payment"
# app/order_service.py
from app.payment import PaymentGateway
def create_order(user_id, amount):
gateway = PaymentGateway()
return gateway.charge(user_id, amount)
导入 order_service 后,模块中已经存在一个名称:
app.order_service.PaymentGateway
它指向 app.payment.PaymentGateway 原对象,但这是一个独立的名称绑定。
因此下面的补丁通常无效:
@patch("app.payment.PaymentGateway")
def test_create_order(mock_gateway):
...
原因是 create_order() 查找的是:
app.order_service.PaymentGateway
而不是再次查找:
app.payment.PaymentGateway
正确写法是:
@patch("app.order_service.PaymentGateway")
def test_create_order(mock_gateway):
mock_gateway.return_value.charge.return_value = "pay-001"
result = create_order(7, 100)
assert result == "pay-001"
官方文档把这条规则概括为:应当补丁化“对象被查找的命名空间”;如果模块使用 from a import SomeClass,应补丁化 b.SomeClass;如果使用 import a 并调用 a.SomeClass,则应补丁化 a.SomeClass。(docs.python.org)
4.3 import 写法决定 Patch 路径
写法一:直接导入名称
from app.payment import PaymentGateway
def create_order(user_id, amount):
return PaymentGateway().charge(user_id, amount)
应当 Patch:
@patch("app.order_service.PaymentGateway")
写法二:导入模块
import app.payment
def create_order(user_id, amount):
return app.payment.PaymentGateway().charge(user_id, amount)
应当 Patch:
@patch("app.payment.PaymentGateway")
判断过程不是“这个类定义在哪里”,而是:
- 进入被测函数;
- 找到它执行的名称;
- 判断这个名称属于哪个命名空间;
- Patch 该命名空间中的名称。
4.4 装饰器顺序容易产生参数错位
多个 patch 装饰器按从下到上的顺序向测试函数传入参数:
@patch("app.order_service.OrderRepository")
@patch("app.order_service.PaymentGateway")
def test_create_order(mock_gateway, mock_repo):
...
最底层的 PaymentGateway Patch 对应第一个参数,外层的 OrderRepository Patch 对应第二个参数。(docs.python.org)
如果担心顺序不直观,可以改用上下文管理器:
def test_create_order():
with (
patch("app.order_service.PaymentGateway") as mock_gateway,
patch("app.order_service.OrderRepository") as mock_repo,
):
...
或者使用 patch.multiple()。该 API 可以在一次调用中替换多个属性;使用 DEFAULT 时由它自动创建 Mock,并以字典或关键字参数形式提供。(docs.python.org)
4.5 patch.object()、patch.dict() 与 PropertyMock
Patch 对象属性
class Clock:
def now(self):
return "real-time"
clock = Clock()
with patch.object(clock, "now", return_value="fixed-time") as mock_now:
assert clock.now() == "fixed-time"
mock_now.assert_called_once_with()
assert clock.now() == "real-time"
Patch 字典或环境配置
import os
from unittest.mock import patch
with patch.dict(os.environ, {"APP_MODE": "test"}):
assert os.environ["APP_MODE"] == "test"
assert os.environ.get("APP_MODE") != "test"
patch.dict() 在作用域结束后恢复字典原状态,也可以使用 clear=True 清除原有内容后再注入测试数据。(docs.python.org)
Patch 属性描述器
class Settings:
@property
def region(self):
return "prod"
with patch(
"__main__.Settings.region",
new_callable=PropertyMock,
) as mock_region:
mock_region.return_value = "test"
assert Settings().region == "test"
mock_region.assert_called_once_with()
PropertyMock 应该放在类的属性上,而不是普通实例属性上,因为 property 本质上是描述器。官方文档也明确建议对类方法、静态方法和属性在类层级进行 Patch。(docs.python.org)
五、Patch 生命周期与清理
5.1 上下文管理器优先表达局部作用域
def test_local_patch():
with patch("app.order_service.PaymentGateway") as mock_gateway:
mock_gateway.return_value.charge.return_value = "pay-001"
assert create_order(7, 100) == "pay-001"
# 这里已经恢复真实 PaymentGateway
Patch 的作用域越小,测试之间发生状态污染的可能性越低。
5.2 start() 与 stop()
当 Patch 数量较多,或者需要在 setUp() 中创建补丁,可以手动控制:
class TestOrderService(unittest.TestCase):
def setUp(self):
self.gateway_patcher = patch(
"app.order_service.PaymentGateway",
autospec=True,
)
self.mock_gateway_class = self.gateway_patcher.start()
self.addCleanup(self.gateway_patcher.stop)
def test_create_order(self):
self.mock_gateway_class.return_value.charge.return_value = "pay-001"
self.assertEqual(create_order(7, 100), "pay-001")
关键是:
self.addCleanup(self.gateway_patcher.stop)
如果只调用 start() 而没有可靠的 stop(),补丁可能泄漏到其他测试,表现为:
- 单独运行测试通过,整套运行失败;
- 测试顺序改变后结果改变;
- 后续测试意外使用前一个测试的 Mock;
- 真实对象永远没有恢复。
unittest.mock 提供 Patcher 的 start() 和 stop(),用于在测试夹具中显式安装和撤销补丁。(docs.python.org)
六、异步测试:调用不等于等待
6.1 协程函数的两个阶段
考虑异步依赖:
class AsyncPaymentGateway:
async def charge(self, user_id: int, amount: int) -> str:
return "pay-001"
调用异步函数时:
coroutine = gateway.charge(7, 100)
此时只是创建了协程对象,并没有得到最终结果。必须等待:
payment_id = await gateway.charge(7, 100)
因此异步测试必须同时关注:
- 异步方法是否被调用;
- 返回的协程是否真的被
await; await时传入了什么参数;- 异步异常是否在等待阶段产生。
6.2 AsyncMock 的行为
AsyncMock 是异步版本的 Mock。调用它会返回可等待对象,真正的返回值或异常在 await 后产生。(docs.python.org)
from unittest.mock import AsyncMock
gateway = AsyncMock()
gateway.charge.return_value = "pay-001"
result = await gateway.charge(7, 100)
assert result == "pay-001"
gateway.charge.assert_awaited_once_with(7, 100)
注意以下区别:
coroutine = gateway.charge(7, 100)
gateway.charge.assert_called_once_with(7, 100)
gateway.charge.assert_not_awaited()
这说明:
assert_called_once_with()只证明调用发生;assert_awaited_once_with()才证明协程被等待;- “调用了异步函数”不等于“异步操作完成”。
6.3 使用 IsolatedAsyncioTestCase
Python 的 unittest.IsolatedAsyncioTestCase 可以直接接收协程测试方法,并为每个测试建立独立的事件循环;测试结束时会取消该循环中的任务。(docs.python.org)
完整示例:
import unittest
from unittest.mock import AsyncMock
class UserClient:
async def fetch_name(self, user_id: int) -> str:
raise NotImplementedError
class UserService:
def __init__(self, client: UserClient):
self.client = client
async def display_name(self, user_id: int) -> str:
name = await self.client.fetch_name(user_id)
return name.strip().title()
class TestUserService(unittest.IsolatedAsyncioTestCase):
async def test_display_name(self):
client = AsyncMock(spec=UserClient)
client.fetch_name.return_value = " alice "
service = UserService(client)
result = await service.display_name(7)
self.assertEqual(result, "Alice")
client.fetch_name.assert_awaited_once_with(7)
运行:
python -m unittest -v
预期输出包含类似结果:
test_display_name (...) ... ok
----------------------------------------------------------------------
Ran 1 test ...
OK
测试的状态变化是:
client.fetch_name.return_value = " alice "
│
▼
display_name(7)
│
├── await client.fetch_name(7)
├── 得到 " alice "
├── strip() 得到 "alice"
├── title() 得到 "Alice"
└── 返回 "Alice"
6.4 patch() 对异步目标的自动行为
当 patch() 识别到目标是异步函数时,会创建 AsyncMock。这一行为从 Python 3.8 起存在。(docs.python.org)
# app/user_service.py
from app.user_client import fetch_user
async def get_name(user_id: int) -> str:
user = await fetch_user(user_id)
return user["name"]
测试:
from unittest.mock import patch
from app.user_service import get_name
class TestGetName(unittest.IsolatedAsyncioTestCase):
@patch("app.user_service.fetch_user")
async def test_get_name(self, mock_fetch_user):
mock_fetch_user.return_value = {"name": "Alice"}
result = await get_name(7)
self.assertEqual(result, "Alice")
mock_fetch_user.assert_awaited_once_with(7)
这里 Patch 的目标仍然是“使用处”:
app.user_service.fetch_user
而不是函数最初定义的模块,原因与同步函数完全相同。
6.5 异步夹具的生命周期
IsolatedAsyncioTestCase 支持:
setUp():同步初始化;asyncSetUp():异步初始化;- 测试协程;
asyncTearDown():异步清理;tearDown():同步清理;addAsyncCleanup():注册异步清理函数。
官方定义的调用顺序是:
setUp
→ asyncSetUp
→ test method
→ asyncTearDown
→ tearDown
→ async cleanup
addAsyncCleanup() 适合注册连接关闭、异步客户端释放等操作。(docs.python.org)
class FakeConnection:
async def close(self):
self.closed = True
class TestConnection(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.connection = FakeConnection()
self.connection.closed = False
self.addAsyncCleanup(self.connection.close)
async def test_connection_is_open(self):
self.assertFalse(self.connection.closed)
如果异步资源是在 asyncSetUp() 中创建的,就应该把清理注册紧邻创建操作,避免测试中途失败后资源无法释放。
七、同步 Mock 与异步 Mock 的常见错误
7.1 用 Mock 替换异步函数
错误示例:
client.fetch_user = Mock(return_value={"name": "Alice"})
result = await service.display_name(7)
Mock 返回的是普通字典,不是可等待对象,因此会出现类似:
TypeError: object dict can't be used in 'await' expression
正确方式:
client.fetch_user = AsyncMock(return_value={"name": "Alice"})
7.2 只检查 called,不检查 awaited
client.fetch_user.assert_called_once_with(7)
这个断言无法证明协程已经被等待。如果生产代码错误地写成:
coroutine = client.fetch_user(7)
return "accepted"
它可能仍然满足 assert_called_once_with(),但异步操作实际上没有完成。
更准确的断言是:
client.fetch_user.assert_awaited_once_with(7)
7.3 没有等待产生的协程警告
如果测试调用了异步 Mock 或真实异步函数,却没有等待:
service.display_name(7)
通常会出现未等待协程的运行时警告。该警告不是“可以忽略的测试噪声”,它说明测试没有执行到真正的异步逻辑。
测试方法必须是:
async def test_display_name(self):
await service.display_name(7)
并由 IsolatedAsyncioTestCase 或其他异步测试运行机制执行。
八、避免脆弱测试:不要把实现细节误当作行为契约
8.1 什么是脆弱测试
脆弱测试是指生产行为没有改变,但测试因为不重要的实现细节改变而失败。
例如生产代码从:
payment_id = gateway.charge(user_id, amount)
repo.save(user_id, amount, payment_id)
重构为:
payment_id = gateway.charge(amount=amount, user_id=user_id)
repo.save(
user_id=user_id,
amount=amount,
payment_id=payment_id,
)
业务语义没有变化,但严格的调用断言可能失败:
gateway.charge.assert_called_once_with(7, 100)
如果参数顺序和关键字形式不是外部契约,这个测试就绑定了实现写法。
8.2 交互断言的强度层级
可以把 Mock 断言按强度分层:
第一层:是否发生
gateway.charge.assert_called()
适合只关心“触发了支付”。
第二层:调用次数
gateway.charge.assert_called_once()
适合业务明确要求只能支付一次的场景。
第三层:参数内容
gateway.charge.assert_called_once_with(7, 100)
适合参数本身属于业务契约的场景。
第四层:完整调用顺序
self.assertEqual(
parent.mock_calls,
[
call.gateway.charge(7, 100),
call.repo.save(7, 100, "pay-001"),
],
)
只有当顺序是业务要求时才应该使用。否则它会把内部重构空间压缩到很小。
例如“必须先扣款,再记录订单”可能是业务要求;但“必须先调用某个日志函数,再调用另一个格式化函数”通常只是实现细节。
8.3 过度断言内部步骤
以下测试通常过于脆弱:
formatter.normalize.assert_called_once_with("Alice")
validator.validate.assert_called_once()
cache.get.assert_called_once_with("user:7")
如果测试真正关心的是:
result == "Alice"
以及缓存未命中时能正确返回结果,那么应优先测试外部可观察行为,而不是内部每一步都必须发生。
更稳定的测试可能是:
self.assertEqual(service.get_display_name(7), "Alice")
只有在某个交互本身就是业务约束时,才添加交互断言。例如:
- 重试次数;
- 不能重复扣款;
- 失败时不能写入数据库;
- 成功后必须发送领域事件;
- 必须使用幂等键。
九、mock_calls 的陷阱:嵌套调用参数可能不完整
mock_calls 可以记录父 Mock、子 Mock、魔术方法和返回值 Mock 的调用:
from unittest.mock import MagicMock, call
client = MagicMock()
client.session("token").send("/users")
print(client.mock_calls)
可能得到:
[
call.session("token"),
call.session().send("/users"),
]
但嵌套返回值上的调用不会完整记录祖先调用的参数。因此:
client.mock_calls[-1] == call.session("different-token").send("/users")
可能仍然比较为真,因为最后一步调用记录中不包含祖先调用参数。官方文档明确指出了这一行为。(docs.python.org)
因此,如果祖先调用参数很重要,应直接保存中间 Mock:
session = client.session.return_value
client.session("token").send("/users")
client.session.assert_called_once_with("token")
session.send.assert_called_once_with("/users")
不要只依赖一条复杂的 mock_calls 列表来表达多层协议。
十、wraps:部分真实执行的替身
wraps 允许 Mock 将调用转发给真实对象,同时保留调用记录:
from unittest.mock import Mock
class Calculator:
def add(self, a, b):
return a + b
real = Calculator()
spy = Mock(wraps=real)
assert spy.add(2, 3) == 5
spy.add.assert_called_once_with(2, 3)
这类对象更接近 Spy:
- 真实逻辑仍然执行;
- 测试可以检查调用;
- 真实逻辑的副作用也可能发生。
因此不要把 wraps 当作“没有风险的 Mock”。如果真实对象会访问数据库、网络或文件系统,wraps 仍然可能产生真实副作用。
十一、MagicMock 与协议方法
MagicMock 是带有预创建魔术方法的 Mock 变体,适合替换容器、上下文管理器、迭代器等实现 Python 协议的对象。(docs.python.org)
11.1 上下文管理器
被测代码:
def read_config(open_func):
with open_func("config.txt") as file:
return file.read()
测试:
from unittest.mock import mock_open, patch
class TestConfig(unittest.TestCase):
def test_read_config(self):
with patch("builtins.open", mock_open(read_data="debug=true")) as mock_file:
result = read_config(open)
self.assertEqual(result, "debug=true")
mock_file.assert_called_once_with("config.txt")
这里 mock_open() 提供了 __enter__() 和 __exit__() 等上下文管理器行为。
11.2 迭代与长度
items = MagicMock()
items.__iter__.return_value = iter(["a", "b"])
items.__len__.return_value = 2
assert list(items) == ["a", "b"]
assert len(items) == 2
如果测试目标只是处理一个列表,直接使用真实列表通常更简单:
items = ["a", "b"]
Mock 只有在需要验证“如何使用该协议”时才值得引入。
十二、测试夹具与 Mock 的隔离
unittest 中每个测试方法都会使用独立的 TestCase 实例,并调用自己的 setUp() 和 tearDown();测试框架默认按测试方法名称排序执行。(docs.python.org)
因此不要把可变 Mock 放在模块级共享:
# 不推荐
gateway = Mock()
class TestService(unittest.TestCase):
def test_a(self):
gateway.charge(1, 100)
def test_b(self):
gateway.charge.assert_not_called()
test_b 是否通过取决于 test_a 是否先运行,以及 Mock 是否被重置。
正确方式是每个测试创建自己的 Mock:
class TestService(unittest.TestCase):
def setUp(self):
self.gateway = Mock()
self.service = OrderService(self.gateway, Mock())
def test_a(self):
self.gateway.charge(1, 100)
def test_b(self):
self.gateway.charge.assert_not_called()
如果确实需要复用同一个 Mock,则必须明确调用:
self.gateway.reset_mock()
但 reset_mock() 默认只清理调用记录,不会自动清除 return_value、side_effect 或普通赋值设置的子属性。(docs.python.org)
因此,测试隔离的优先级通常是:
- 每个测试创建新 Mock;
- 通过
setUp()或局部上下文管理器配置; - 只有在生命周期明确时才使用
reset_mock(); - 避免依赖测试执行顺序。
十三、什么时候不应该使用 Mock
13.1 纯函数不需要 Mock
def calculate_total(price: int, quantity: int) -> int:
return price * quantity
测试:
class TestCalculateTotal(unittest.TestCase):
def test_total(self):
self.assertEqual(calculate_total(20, 3), 60)
为纯函数制造 Mock 不会增加信息,反而会遮蔽真实计算。
13.2 简单数据结构优先使用真实对象
如果代码需要一个列表、字典或简单配置对象,直接构造真实值通常更清晰:
config = {"timeout": 3, "retries": 2}
而不是:
config = Mock()
config.timeout = 3
config.retries = 2
真实对象能够验证类型、协议和默认行为;Mock 只能验证测试作者事先配置过的行为。
13.3 数据库、消息队列和 HTTP 不应全部 Mock
Mock 适合单元测试,但无法证明:
- SQL 是否真的能执行;
- 数据库事务是否正确提交或回滚;
- 消息是否能被真实消费者解析;
- HTTP 请求格式是否符合服务端要求;
- 序列化字段是否与对方协议一致。
这些问题需要集成测试。例如数据库和消息队列可以使用隔离的真实依赖、容器化环境或专门的测试实例。单元测试与集成测试的关系不是二选一:
单元测试:
业务逻辑 + Mock 协作者
集成测试:
业务逻辑 + 真实数据库 / 消息队列 / HTTP 服务
端到端测试:
用户入口 + 多个真实组件 + 完整系统
如果所有边界都被 Mock,测试可能只证明“Mock 按照测试设置返回了测试设置的值”。
十四、与 pytest 的关系
pytest 可以直接运行 unittest 测试套件,因此团队可以保留 unittest.TestCase 和 unittest.mock,同时使用 pytest 的发现、报告、Fixture、参数化和插件能力。pytest 官方文档将运行 unittest 测试列为内置能力之一。(docs.pytest.org)
例如:
pytest -q
可以运行:
import unittest
class TestMath(unittest.TestCase):
def test_add(self):
self.assertEqual(1 + 1, 2)
但两套模型的夹具机制并不完全相同:
unittest主要使用setUp()、tearDown()、addCleanup();- pytest 主要使用函数参数注入 Fixture;
unittest.TestCase方法不能像普通 pytest 测试函数那样直接接收任意 Fixture 参数。
因此混用时应保持边界清晰:
class TestService(unittest.TestCase):
def setUp(self):
self.gateway = Mock()
或者把测试完整地写成 pytest 风格:
def test_service(gateway_mock):
...
不要在同一个测试中同时依赖多套生命周期机制,否则清理顺序和资源来源会变得难以判断。
十五、诊断 Mock 测试失败的顺序
当 Mock 测试失败时,可以按照调用链检查。
第一步:确认 Patch 路径
检查被测模块实际查找的名称:
# 是 app.service.Client?
# 还是 app.client.Client?
优先查看被测文件的导入方式:
from app.client import Client
对应:
patch("app.service.Client")
如果是:
import app.client
对应:
patch("app.client.Client")
第二步:检查替换是否真的发生
with patch("app.service.Client") as mock_client:
print(mock_client)
print(app.service.Client is mock_client)
如果身份不一致,说明 Patch 目标可能错误,或者被测模块在 Patch 之前已经保存了其他引用。
第三步:检查调用与等待
同步:
mock.assert_called_once_with(...)
异步:
mock.assert_awaited_once_with(...)
不要用同步断言替代异步断言。
第四步:检查返回值层级
如果生产代码写的是:
response = client.fetch()
return response["name"]
Mock 应配置:
client.fetch.return_value = {"name": "Alice"}
如果生产代码写的是:
response = await client.fetch()
则应配置:
client.fetch = AsyncMock(return_value={"name": "Alice"})
不要误写成:
client.fetch.return_value["name"] = "Alice"
除非你明确希望返回值本身是一个 Mock 字典结构。
第五步:检查是否验证了错误的层级
失败信息:
Expected: mock(7, 100)
Actual: not called
可能不是业务逻辑没有调用,而是:
- Patch 了定义处;
- 被测函数引用了另一个名称;
- 异步调用尚未执行;
- 代码在前置校验阶段提前抛出了异常;
- 测试配置的是实例 Mock,但生产代码创建了新的实例。
诊断时应先确认控制流,再调整断言,而不是直接放宽断言。
十六、一个完整示例:同步、失败、重试和异步
下面的示例集中展示替身边界。
# app/notification.py
class NotificationClient:
def send(self, user_id: int, message: str) -> None:
raise NotImplementedError
# app/account_service.py
class AccountService:
def __init__(self, notification_client):
self.notification_client = notification_client
def disable_account(self, user_id: int) -> None:
if user_id <= 0:
raise ValueError("invalid user id")
self.notification_client.send(
user_id,
"account disabled",
)
测试:
# tests/test_account_service.py
import unittest
from unittest.mock import Mock, create_autospec
from app.account_service import AccountService
from app.notification import NotificationClient
class TestAccountService(unittest.TestCase):
def setUp(self):
self.client = create_autospec(
NotificationClient,
instance=True,
)
self.service = AccountService(self.client)
def test_disables_account_and_sends_notification(self):
self.service.disable_account(7)
self.client.send.assert_called_once_with(
7,
"account disabled",
)
def test_rejects_invalid_user_id_without_notification(self):
with self.assertRaisesRegex(ValueError, "invalid"):
self.service.disable_account(0)
self.client.send.assert_not_called()
这个测试套件验证了:
- 正常路径的业务效果;
- 通知参数;
- 非法输入;
- 非法输入不会产生副作用;
- Mock 接口必须符合
NotificationClient。
但它没有验证真实通知系统是否可用。这正是替身边界的意义:测试对当前目标负责,不假装覆盖边界外的事实。
结语:Mock 的价值取决于边界,而不是数量
Mock 不是“让测试更容易通过”的工具,而是用于控制测试输入、隔离外部依赖并观察协作者交互的工具。
可靠使用 Mock 需要同时掌握四层机制:
- 替身边界:明确谁是被测对象,谁是协作者;
- 接口约束:使用
spec、spec_set和autospec防止测试漂移; - 名称绑定:Patch 被测代码查找的名称,而不是盲目 Patch 定义位置;
- 生命周期与异步语义:确保 Patch 恢复、资源清理,并区分调用与等待。
脆弱测试的根源通常不是 Mock 本身,而是测试把实现细节误写成了行为契约。只验证业务真正要求的结果和交互,保留内部重构空间,再用集成测试验证真实基础设施,测试套件才会同时具备速度、可信度和维护性。
系列导航与关联阅读
- 系列入口:Python 完整学习路线:从语言模型、并发到 Web、数据、AI 与生产交付
- 上一篇:Python pytest 完整基础:Fixture、参数化、标记、插件和隔离
- 下一篇:Python 属性测试:Hypothesis、生成策略、缩减和状态机测试
- 延伸:Python 集成测试:Testcontainers、数据库、消息队列和稳定隔离
官方资料
本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论