Python 基础体系 · 第 68/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python unittest 与 Mock:替身边界、Patch、异步和脆弱测试

单元测试的难点通常不是“如何断言一个返回值”,而是如何确定测试边界:哪些代码应该真实执行,哪些依赖应该被替换,测试究竟要验证业务行为,还是验证某个内部调用细节。

unittest 提供测试用例、夹具、断言、测试发现和运行器;unittest.mock 则提供 MockMagicMockAsyncMockpatch() 等工具,用于替换被测代码依赖并记录交互。Python 3.14 的 unittest 还支持默认彩色输出,并改进了命名空间包作为测试发现起点时的行为。(docs.python.org)

本文围绕四个问题展开:

  1. 测试替身应该放在哪条边界上;
  2. patch() 到底替换了什么,以及为什么经常“打了补丁但没有生效”;
  3. 同步代码、异步代码和异步资源的测试有何不同;
  4. 为什么有些测试虽然全是绿色,却极其脆弱。

一、先建立测试边界:被测对象与协作者

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 是协作者;
  • “金额必须为正数”是业务规则;
  • “支付成功后保存订单”是与协作者之间的交互协议。

测试边界可以形式化为:

Test Result=F(Input,SUT Logic,Collaborator Behavior)\text{Test Result} = F(\text{Input}, \text{SUT Logic}, \text{Collaborator Behavior})

其中:

  • 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)

这里发生了两件事:

  1. return_valuegateway.charge() 返回固定结果;
  2. 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 两类可观察结果

一个测试通常观察两类结果:

O=(R,I)O = (R, I)

其中:

  • RR:返回结果或异常;
  • II:与协作者之间的交互记录。

例如:

result = service.place_order(7, 100)

可以断言:

self.assertEqual(result, "pay-001")

这是对 RR 的断言。

也可以断言:

gateway.charge.assert_called_once_with(7, 100)
repo.save.assert_called_once_with(7, 100, "pay-001")

这是对 II 的断言。

关键在于:交互断言不是越多越好。它应该描述业务协议,而不是描述实现中的每一个局部动作。


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()

这里的因果关系是:

  1. 输入 amount=0
  2. 被测对象在调用协作者之前完成校验;
  3. 校验失败并抛出 ValueError
  4. 因为控制流提前结束,支付和保存都不会发生。

如果只写:

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 的接口边界:specspec_setautospec

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_setspec 更严格:不仅不能读取不存在的属性,也不能给不存在的属性赋值。官方文档将其定义为 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"

接口约束的目标不是让测试变得更严格,而是让错误尽可能接近真实代码暴露:

Mock APIReal API\text{Mock API} \approx \text{Real API}

其中近似关系至少应覆盖:

  • 属性名称;
  • 方法名称;
  • 是否可调用;
  • 参数数量;
  • 参数名称;
  • 异步方法与同步方法的区别。

四、patch() 的核心:替换名称绑定,而不是修改“原对象”

4.1 patch() 做了什么

patch() 的目标形式通常是:

"模块路径.属性名"

例如:

@patch("app.order_service.PaymentGateway")
def test_something(mock_gateway):
    ...

它的生命周期是:

  1. 导入目标模块;
  2. 找到模块中的目标属性;
  3. 保存原属性;
  4. 将属性替换为新对象或自动创建的 Mock;
  5. 执行测试;
  6. 测试结束后恢复原属性。

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")

判断过程不是“这个类定义在哪里”,而是:

  1. 进入被测函数;
  2. 找到它执行的名称;
  3. 判断这个名称属于哪个命名空间;
  4. 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)

因此异步测试必须同时关注:

  1. 异步方法是否被调用;
  2. 返回的协程是否真的被 await
  3. await 时传入了什么参数;
  4. 异步异常是否在等待阶段产生。

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_valueside_effect 或普通赋值设置的子属性。(docs.python.org)

因此,测试隔离的优先级通常是:

  1. 每个测试创建新 Mock;
  2. 通过 setUp() 或局部上下文管理器配置;
  3. 只有在生命周期明确时才使用 reset_mock()
  4. 避免依赖测试执行顺序。

十三、什么时候不应该使用 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.TestCaseunittest.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 需要同时掌握四层机制:

  1. 替身边界:明确谁是被测对象,谁是协作者;
  2. 接口约束:使用 specspec_setautospec 防止测试漂移;
  3. 名称绑定:Patch 被测代码查找的名称,而不是盲目 Patch 定义位置;
  4. 生命周期与异步语义:确保 Patch 恢复、资源清理,并区分调用与等待。

脆弱测试的根源通常不是 Mock 本身,而是测试把实现细节误写成了行为契约。只验证业务真正要求的结果和交互,保留内部重构空间,再用集成测试验证真实基础设施,测试套件才会同时具备速度、可信度和维护性。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。