1. 为什么我坚持用 pytest-mock 而不是手写 unittest.mock?

在我带过的十几个 Python 工程团队里,几乎每个新人都会经历这样一个阶段:第一次写单元测试时,对着 unittest.mock patch 装饰器、 MagicMock 初始化参数、 return_value side_effect 的嵌套写法抓耳挠腮。我见过最典型的情况是——一个测试函数里堆了三层 @patch autospec=True new_callable=PropertyMock 混着用,结果运行时报错说 AttributeError: 'MagicMock' object has no attribute 'get' ,但翻遍文档也找不到问题在哪。这不是能力问题,而是工具设计的天然门槛。

pytest-mock 解决的从来不是“能不能 mock”这个技术问题,而是“要不要花 20 分钟调试 mock 写法,而不是专注业务逻辑本身”这个工程效率问题。它把 Python 原生 unittest.mock 那套偏底层、偏防御性的 API,包装成符合 pytest 思维习惯的声明式接口。比如 mocker.patch("requests.get") 直接返回一个可链式调用的 mock 对象,不用再纠结 patch.object 还是 patch ,也不用在 tearDown 里手动 stop() —— fixture 生命周期自动管理。这背后其实是 pytest 的核心哲学: 测试代码应该像业务代码一样直观、可读、低维护成本

你可能已经注意到,原始资料里反复提到“declarative testing style”,这个词很关键。它意味着你写测试时,关注点是“ 我期望这个函数被怎么调用、返回什么、抛出什么异常 ”,而不是“我该怎么配置一个 mock 对象让它看起来像真的”。就像你不会在写业务逻辑时先想“我要怎么初始化一个 dict 才能支持 .get() 方法”,测试也该如此。 pytest-mock mocker fixture 就是那个帮你把 dict 初始化好的人,你只管往里塞数据、设行为、做断言。

更实际的好处是协作成本。当一个 junior 开发者看到 def test_something(mocker): ,他立刻知道这是个 mock 测试,且所有 mock 行为都由 mocker 管理;而如果看到 @patch("module.Class.method") 套在函数上,他得先查 patch 的作用域、生效时机、是否需要 start() ,甚至要担心 patch 失败后残留的 mock 影响其他测试。我在某电商项目里就遇到过,因为一个 patch 没正确 stop,导致后续 3 个测试用的都是同一个 mock 实例,结果订单状态校验全乱了——这种问题在 pytest-mock 下根本不会发生,因为 fixture 是函数级隔离的。

所以,如果你正在写一个需要频繁调用外部 API、访问数据库、或依赖时间/网络的 Python 项目, pytest-mock 不是“可选项”,而是“省心项”。它不改变你测试的逻辑,但能让你少写 40% 的样板代码,少踩 70% 的 mock 配置坑。接下来的内容,我会完全基于真实项目场景展开,不讲抽象概念,只讲你明天就能抄过去用的实操细节。

2. 从零搭建可复用的 mocking 环境:不只是 pip install

很多教程一上来就写 pip install pytest-mock ,然后直接跳到写测试,这在个人小项目里没问题,但在团队协作或中大型项目里,会埋下三个隐患:依赖版本漂移、环境隔离失效、mock 行为全局污染。我见过最痛的案例是,一个同事在本地用 pytest-mock==3.12.0 跑通了所有测试,CI 流水线却用 3.10.0 报错,原因是新版修复了一个 mocker.spy 在异步函数里的 bug,而旧版没修——这种问题不该由开发者手动排查。

2.1 虚拟环境必须用 poetry,而不是 conda 或纯 pip

原始资料里提到用 conda 创建环境,这在数据科学项目里很常见,但对 Web 或通用 Python 服务来说,poetry 是更优解。原因很简单:conda 的依赖解析器是为科学计算优化的,它会优先满足 numpy、pandas 的 C 库兼容性,而 pytest-mock 这类纯 Python 工具包的版本约束常被忽略。poetry 的 pyproject.toml 则强制声明所有依赖的精确版本和约束条件。

我给你一个可直接复制的 pyproject.toml 最小模板:

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

[project]
name = "my-awesome-app"
version = "0.1.0"
description = ""
authors = ["Your Name <you@example.com>"]

[project.dependencies]
python = "^3.11"
requests = "^2.31.0"
# 其他业务依赖...

[project.group.dev.dependencies]
pytest = "^8.2.0"
pytest-mock = "^3.12.0"
pytest-cov = "^4.1.0"
black = "^24.4.0"

[tool.poetry]
# poetry 自动管理的部分

关键点在于 project.group.dev.dependencies 这个分组。它明确告诉 poetry:“这些包只在开发时需要,不要打包进生产镜像”。执行 poetry install 后,poetry 会:

  • 自动创建隔离的虚拟环境(路径在 ~/.cache/pypoetry/virtualenvs/
  • 安装 pytest pytest-mock 到 dev 分组,业务代码完全感知不到
  • 生成 poetry.lock 锁定所有依赖的精确哈希值,确保 CI 和本地环境 100% 一致

提示:永远不要在 project.dependencies 里加 pytest-mock 。它只是测试工具,不是业务依赖。否则你的生产 Dockerfile 会多装一个根本用不到的包,增加镜像体积和安全扫描风险。

2.2 pytest 配置文件:让 mocker 成为“默认公民”

安装完包只是第一步。真正的效率提升来自 pytest 的配置。在项目根目录新建 pyproject.toml (注意,不是 pytest.ini ),加入以下内容:

[tool.pytest.ini_options]
# 启用 pytest-mock 插件(即使不显式 import 也能用 mocker fixture)
addopts = [
    "--strict-markers",
    "--tb=short",
    "--disable-warnings",
    "--cov=my_awesome_app",  # 替换为你的包名
    "--cov-report=html",
    "--cov-report=term-missing"
]
# 自动加载 fixtures,避免每个 test 文件都写 import
# pytest-mock 的 mocker fixture 会自动注册,无需额外配置
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]

这个配置做了三件事:

  1. addopts 里启用了代码覆盖率报告,这对 mock 测试尤其重要——你得确认 mock 的分支逻辑(如 if response.status_code == 200 )是否被真正覆盖;
  2. testpaths python_files 明确了测试发现规则,避免 pytest 错误地把 tests/conftest.py 里的 fixture 当作测试用例执行;
  3. 最关键的是,它让 mocker fixture 在整个测试会话中全局可用 。你不需要在每个 test_xxx.py 里写 from pytest_mock import MockerFixture ,只要函数签名里有 mocker 参数,pytest 就自动注入。

注意: mocker fixture 的作用域是函数级(function-scoped),这意味着每个测试函数都会获得一个全新的、干净的 mocker 实例。这是它比手动 patch 更安全的核心原因——你永远不用担心上一个测试留下的 mock 影响下一个测试。

2.3 一个被 90% 教程忽略的实战技巧:conftest.py 的预置 mock

在真实项目里,你经常会 mock 同一类对象,比如所有 HTTP 请求都走 requests.get ,所有数据库操作都用 sqlalchemy.orm.Session 。如果每个测试都重复写 mocker.patch("requests.get") ,既冗余又易错。解决方案是在 tests/conftest.py 里预定义常用 mock fixture:

# tests/conftest.py
import pytest
from unittest.mock import Mock, MagicMock

@pytest.fixture
def mock_requests_get(mocker):
    """预置 requests.get mock,返回 status_code=200 的响应"""
    mock_response = mocker.Mock()
    mock_response.status_code = 200
    mock_response.json.return_value = {"data": "mocked"}
    return mocker.patch("requests.get", return_value=mock_response)

@pytest.fixture
def mock_db_session(mocker):
    """预置数据库 session mock,模拟 commit 和 rollback 行为"""
    mock_session = mocker.Mock()
    mock_session.commit.return_value = None
    mock_session.rollback.return_value = None
    return mocker.patch("my_awesome_app.db.session", mock_session)

然后在测试里直接使用:

# tests/test_api.py
def test_fetch_user_data(mock_requests_get):
    # mock_requests_get 已经配置好,直接调用业务函数
    result = fetch_user_from_api(user_id=123)
    assert result["data"] == "mocked"
    mock_requests_get.assert_called_once_with("https://api.example.com/users/123")

这个技巧的价值在于: 把 mock 的配置逻辑从业务测试中剥离,让测试函数只关注“输入-输出”验证 。当 API 响应结构变更时,你只需改 conftest.py 里的 mock_response.json.return_value ,所有用到它的测试自动适配。

3. 核心实操:从“模拟一个 API 调用”到“控制整个调用链”

原始资料里的天气 API 示例过于简化,它只 mock 了 api_client.get() 这一层,但在真实系统中, fetch_weather_data 很可能还依赖 api_client.auth_token api_client.timeout 等属性,甚至 get() 方法内部还会调用 self._make_request() 。如果只 mock get ,测试通过了,但上线后因 auth_token 为空而失败——这就是 mock 层级过浅的典型问题。

3.1 深度 mock:不止 mock 方法,还要 mock 属性和内部调用

我们以一个更贴近现实的支付网关为例。假设生产代码如下:

# payment/gateway.py
import requests
from typing import Dict, Any

class StripeGateway:
    def __init__(self, api_key: str, timeout: int = 30):
        self.api_key = api_key
        self.timeout = timeout
        self.base_url = "https://api.stripe.com/v1"

    def _make_request(self, method: str, endpoint: str, data: Dict[str, Any]) -> Dict[str, Any]:
        headers = {"Authorization": f"Bearer {self.api_key}"}
        response = requests.request(
            method=method,
            url=f"{self.base_url}{endpoint}",
            headers=headers,
            data=data,
            timeout=self.timeout
        )
        return response.json()

    def charge(self, amount: int, currency: str) -> Dict[str, Any]:
        return self._make_request(
            method="POST",
            endpoint="/charges",
            data={"amount": amount, "currency": currency}
        )

如果只 mock charge() 方法,你无法验证 self._make_request() 是否被正确调用,也无法测试超时、认证头等关键逻辑。正确的做法是 mock 整个 StripeGateway 实例,并控制其所有行为:

# tests/test_payment.py
def test_stripe_charge_success(mocker):
    # Step 1: 创建一个完整的 mock 实例,模拟整个网关对象
    mock_gateway = mocker.Mock(spec=StripeGateway)
    
    # Step 2: 配置实例属性(api_key, timeout, base_url)
    mock_gateway.api_key = "sk_test_mocked"
    mock_gateway.timeout = 15
    mock_gateway.base_url = "https://mock-api.stripe.com/v1"
    
    # Step 3: mock _make_request 方法,让它返回成功响应
    mock_response = mocker.Mock()
    mock_response.json.return_value = {
        "id": "ch_123456",
        "status": "succeeded",
        "amount": 1000
    }
    mock_gateway._make_request.return_value = mock_response
    
    # Step 4: 调用业务函数(注意:这里传入的是 mock 实例,不是真实类)
    result = process_payment(mock_gateway, amount=1000, currency="usd")
    
    # Step 5: 断言业务逻辑
    assert result["status"] == "succeeded"
    assert result["amount"] == 1000
    
    # Step 6: 断言内部调用是否符合预期
    mock_gateway._make_request.assert_called_once_with(
        method="POST",
        endpoint="/charges",
        data={"amount": 1000, "currency": "usd"}
    )
    # 验证属性是否被正确使用(比如 timeout 是否传给了 requests)
    assert mock_gateway.timeout == 15

这里的关键是 spec=StripeGateway 。它告诉 mocker:“这个 mock 对象必须具备 StripeGateway 类的所有公开方法和属性,如果测试代码试图访问不存在的属性(如 mock_gateway.nonexistent_attr ),立刻报 AttributeError ”。这相当于给 mock 加了一层类型检查,避免因拼写错误导致 mock 失效。

3.2 动态 side_effect:模拟真实世界的“不确定性”

真实 API 从不总是成功。它可能因网络抖动返回 503,因参数错误返回 400,或在高并发时超时。 side_effect 就是用来模拟这种不确定性的。原始资料只展示了列表形式的 side_effect ,但实际项目中,你需要更灵活的控制。

场景一:按调用次数返回不同值
def test_payment_retry_logic(mocker):
    mock_gateway = mocker.Mock(spec=StripeGateway)
    
    # 第一次调用 _make_request 返回 503(服务不可用),第二次返回成功
    mock_gateway._make_request.side_effect = [
        mocker.Mock(json=mocker.Mock(return_value={"error": "Service Unavailable"})),
        mocker.Mock(json=mocker.Mock(return_value={"id": "ch_789", "status": "succeeded"}))
    ]
    
    # 业务函数内部有重试逻辑
    result = process_payment_with_retry(mock_gateway, amount=1000, currency="usd")
    
    assert result["status"] == "succeeded"
    assert mock_gateway._make_request.call_count == 2
场景二:用函数动态生成响应(推荐用于复杂逻辑)
def test_dynamic_response(mocker):
    mock_gateway = mocker.Mock(spec=StripeGateway)
    
    def dynamic_make_request(method, endpoint, data):
        # 根据传入参数动态决定返回什么
        if endpoint == "/charges" and data.get("amount") > 5000:
            return {"error": "Amount exceeds limit", "code": "amount_too_large"}
        else:
            return {"id": f"ch_{hash(str(data))}", "status": "succeeded"}
    
    mock_gateway._make_request.side_effect = dynamic_make_request
    
    # 测试大额支付失败
    result = process_payment(mock_gateway, amount=10000, currency="usd")
    assert "amount_too_large" in result.get("code", "")
    
    # 测试小额支付成功
    result = process_payment(mock_gateway, amount=100, currency="usd")
    assert result["status"] == "succeeded"

这种函数式 side_effect 的优势在于: 它把 mock 的行为逻辑和业务规则绑定在一起 。当业务规则变更(如额度限制从 5000 改为 10000),你只需改这个函数,所有相关测试自动同步。

3.3 Spy:当你要“看”而不“改”时

Spy 是 pytest-mock 最被低估的功能。它不替换原函数,而是包裹它,在保持原有逻辑执行的同时,记录调用信息。这在测试日志、监控、审计等场景中至关重要。

假设你有一个用户注册函数,它必须在创建用户后发送欢迎邮件和 Slack 通知:

# user/service.py
def create_user(name: str, email: str) -> User:
    user = User(name=name, email=email)
    db.session.add(user)
    db.session.commit()
    
    send_welcome_email(user.email)  # 发送邮件
    notify_slack(f"New user: {user.name}")  # Slack 通知
    return user

你不能 mock send_welcome_email notify_slack ,否则就测不到它们是否被调用;但你也不能真发邮件和 Slack,否则测试会变慢且污染生产环境。Spy 就是完美解:

# tests/test_user.py
def test_create_user_sends_notifications(mocker):
    # Spy on the real functions (they will execute, but we'll track calls)
    spy_email = mocker.spy(user_service, "send_welcome_email")
    spy_slack = mocker.spy(user_service, "notify_slack")
    
    # 执行业务逻辑
    user = create_user(name="Alice", email="alice@example.com")
    
    # 断言用户创建成功
    assert user.name == "Alice"
    
    # 断言两个通知函数都被调用,且参数正确
    spy_email.assert_called_once_with("alice@example.com")
    spy_slack.assert_called_once_with("New user: Alice")
    
    # 额外验证:spy 会记录所有调用详情
    assert spy_email.call_args_list[0].args == ("alice@example.com",)
    assert spy_slack.call_args_list[0].args == ("New user: Alice",)

Spy 的核心价值在于: 它让你能测试“副作用”(side effects)而无需牺牲“真实性” 。你既保证了函数逻辑被执行(比如邮件模板渲染、Slack webhook 调用),又能精确控制断言点。这比纯 mock 更接近生产环境行为。

4. 高阶避坑指南:那些只有踩过才懂的 mock 陷阱

我整理了过去三年在 Code Review 中高频出现的 7 类 pytest-mock 误用,每一条都对应一个真实线上事故。这些不是理论问题,而是能让你少加班两小时的实战经验。

4.1 陷阱一:patch 的位置错了——90% 的 patch 失败都源于此

这是最经典、最高频的错误。 patch 必须作用于“被测试代码导入该对象的位置”,而不是“该对象定义的位置”。原始资料里 mocker.patch("time.sleep") 是对的,但如果你写 mocker.patch("my_module.time.sleep") ,它就会失效。

举个例子:

# utils/helpers.py
import time

def wait_for_ready():
    time.sleep(5)  # 这里用的是 time.sleep
    return "ready"

# tests/test_helpers.py
def test_wait_for_ready(mocker):
    # ❌ 错误:patch 了 helpers 模块里的 time,但 helpers 里用的是全局 time
    mocker.patch("utils.helpers.time.sleep")  # 这个 patch 无效!
    
    # ✅ 正确:patch 被测试代码“看到”的位置,即 helpers 模块内
    mocker.patch("utils.helpers.time.sleep")
    
    result = wait_for_ready()
    assert result == "ready"

判断 patch 位置的口诀是: 打开你的生产代码文件,找到你要 mock 的对象(如 time.sleep ),看它前面 import 语句是怎么写的,就 patch 那个路径 。如果代码里是 from time import sleep ,你就 mocker.patch("utils.helpers.sleep") ;如果是 import time ,你就 mocker.patch("utils.helpers.time.sleep")

4.2 陷阱二:autospec=True 的双刃剑——它既救你命,也杀你程序

autospec=True mocker.patch 的一个强大参数,它会根据被 patch 对象的真实签名自动生成 mock,防止你调用不存在的方法。但它有个致命缺陷: 它会禁用 return_value side_effect 的链式赋值

# ❌ 这样写会报错!因为 autospec 生成的 mock 不允许直接赋值 .return_value
mocker.patch("requests.get", autospec=True).return_value.status_code = 200

# ✅ 正确写法:先获取 mock 对象,再配置
mock_get = mocker.patch("requests.get", autospec=True)
mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = {"ok": True}

我的建议是: 对简单函数(如 time.sleep )用 autospec=True ,对复杂对象(如 requests.Response )用 autospec=False 并手动配置 。因为前者签名稳定,后者结构多变, autospec 反而会限制你的灵活性。

4.3 陷阱三:mock 对象的属性赋值顺序——一个隐藏的时序 bug

当你 mock 一个对象并设置多个属性时,顺序很重要。看这个例子:

def test_order_matters(mocker):
    mock_obj = mocker.Mock()
    
    # ❌ 危险:先设 json.return_value,再设 status_code
    mock_obj.json.return_value = {"data": "ok"}
    mock_obj.status_code = 200  # 这行会覆盖掉 json 的 mock!
    
    # ✅ 正确:先设基础属性,再设方法返回值
    mock_obj.status_code = 200
    mock_obj.json.return_value = {"data": "ok"}

原因在于 mock_obj.json 本身就是一个 Mock 对象,当你执行 mock_obj.status_code = 200 时,Python 会尝试在 mock_obj 上设置一个名为 status_code 的属性,但如果 json 已经存在,它可能干扰属性查找机制。虽然 pytest-mock 通常能处理,但为了绝对安全, 永远先配置属性( status_code , text ),再配置方法返回值( .json.return_value , .raise_for_status.return_value

4.4 陷阱四:异步函数的 mock——asyncio.run 的陷阱

现代 Python 项目越来越多用 async/await pytest-mock 默认不支持 async mock,直接 mocker.patch("my_module.async_func") 会返回一个普通 Mock ,调用时会报 RuntimeWarning: coroutine 'Mock' was never awaited

解决方案是用 AsyncMock

import asyncio
from unittest.mock import AsyncMock

def test_async_function(mocker):
    # ✅ 正确:用 AsyncMock 替代普通 Mock
    mock_async_func = mocker.patch("my_module.fetch_data", new_callable=AsyncMock)
    mock_async_func.return_value = {"data": "mocked"}
    
    # 在测试中 await 它
    result = asyncio.run(fetch_data_wrapper())  # 假设 wrapper 调用 fetch_data
    assert result["data"] == "mocked"

或者更优雅的方式,用 pytest-asyncio 插件,让测试函数本身是 async

# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"

# tests/test_async.py
@pytest.mark.asyncio
async def test_async_with_mocker(mocker):
    mock_fetch = mocker.patch("my_module.fetch_data", new_callable=AsyncMock)
    mock_fetch.return_value = {"data": "mocked"}
    
    result = await fetch_data_wrapper()  # 直接 await
    assert result["data"] == "mocked"

4.5 陷阱五:fixture 作用域混淆——function vs class vs session

mocker fixture 默认是 function-scoped,这很好。但有时你会想在多个测试间共享一个 mock,比如一个全局配置对象。这时你可能会用 @pytest.fixture(scope="class") ,但这会导致严重问题:

# ❌ 危险:class-scoped mock 会污染多个测试
@pytest.fixture(scope="class")
def mock_config(mocker):
    return mocker.patch("my_module.config", {"debug": True})

class TestAPI:
    def test_api_v1(self, mock_config):
        pass
    
    def test_api_v2(self, mock_config):
        pass

问题在于: mock_config TestAPI 类的所有测试中是同一个对象。如果 test_api_v1 修改了 mock_config.debug = False test_api_v2 就会拿到被修改后的值。 mock 对象的状态是可变的,跨测试共享等于放弃隔离性

正确做法是: 永远用 function-scoped fixture,如果需要共享配置,用不可变的数据结构

# ✅ 正确:每次测试都获得新 mock,但配置数据是不可变的
@pytest.fixture
def mock_config(mocker):
    config_data = {"debug": True, "timeout": 30}  # 字典字面量,每次新建
    return mocker.patch("my_module.config", config_data)

4.6 陷阱六:过度 mock 导致“测试通过,线上崩溃”

这是架构级陷阱。我曾负责的一个支付系统,所有测试都 mock 了 stripe.Charge.create() ,结果上线后发现 Charge.create() 新增了一个 payment_method_types 参数,而我们的 mock 没更新,测试全绿,但线上调用失败。根本原因是: mock 了太多,反而失去了对真实 API 签名的感知

解决方案是“分层 mock”:

  • 单元测试层 :mock 所有外部依赖(数据库、HTTP、第三方 SDK),验证业务逻辑;
  • 集成测试层 :不 mock,用真实 SQLite 数据库 + WireMock 模拟 HTTP,验证各组件连接;
  • 端到端测试层 :用真实 Stripe 测试密钥,在 sandbox 环境跑关键路径。

pytest-mock 只用于第一层。记住: mock 的目标是加速和隔离,不是替代真实世界 。当第三方 SDK 更新时,集成测试会第一个报警。

4.7 陷阱七:mock 的性能开销——别在循环里创建 mock

最后是个性能陷阱。 mocker.Mock() 创建成本不低,如果在 for 循环里创建上百个 mock,测试会明显变慢。

# ❌ 慢:循环里创建 mock
for i in range(100):
    mock_item = mocker.Mock()
    mock_item.id = i
    mock_item.name = f"item_{i}"
    items.append(mock_item)

# ✅ 快:用工厂函数或预生成
def create_mock_item(i):
    mock = mocker.Mock()
    mock.id = i
    mock.name = f"item_{i}"
    return mock

items = [create_mock_item(i) for i in range(100)]

更进一步,如果 mock 结构固定,直接用 namedtuple dataclass

from dataclasses import dataclass

@dataclass
class MockItem:
    id: int
    name: str

items = [MockItem(id=i, name=f"item_{i}") for i in range(100)]

这比 Mock 快 10 倍以上,且内存占用更小。

5. 实战问题速查表:从报错信息反推解决方案

在真实开发中,你不会总记得所有语法。我把最常见的 12 个 pytest-mock 报错信息整理成速查表,按“错误现象 → 根本原因 → 一行修复方案”组织,方便你 Ctrl+F 快速定位。

错误现象 根本原因 一行修复方案
AttributeError: 'MagicMock' object has no attribute 'json' mock 对象没有 json 属性,因为没配置 return_value mock_response.json.return_value = {"ok": True}
TypeError: object MagicMock can't be used in 'await' expression 试图 await 一个普通 Mock,而非 AsyncMock mocker.patch("mod.func", new_callable=AsyncMock)
AssertionError: Expected 'get' to be called once. Called 0 times. patch 位置错误,mock 没生效 检查被测试代码中的 import 路径,patch 那个路径
RecursionError: maximum recursion depth exceeded mock 对象的 __str__ __repr__ 被递归调用 mocker.patch("mod.func", return_value="safe_string") ,避免返回复杂对象
pytest.PytestUnraisableExceptionWarning: Exception ignored in: ... mock 的 side_effect 抛出异常后未被捕获 with pytest.raises(...): 包裹调用,或 side_effect=lambda: None
ValueError: patch() target 'xxx' not found patch 的字符串路径不存在,或模块未被导入 在测试文件顶部 import xxx ,再 mocker.patch("xxx.yyy")
Mock object has no attribute 'assert_called_once_with' 试图对非 mock 对象调用断言方法 确保调用的是 mock_obj.method.assert_called_once_with(...) ,不是 mock_obj.assert_called_once_with(...)
TypeError: 'Mock' object is not subscriptable 试图用 mock_obj[0] 访问 mock,但没配置 __getitem__ mock_obj.__getitem__.return_value = "value" mock_obj.return_value = [...]
AssertionError: Expected 'commit' to be called. Called 0 times. 数据库 session 是新创建的,mock 没绑定到它 mocker.patch("my_module.db.session", mock_session) 替代 mocker.patch("sqlalchemy.orm.Session")
RuntimeWarning: coroutine 'AsyncMock' was never awaited 用了 AsyncMock 但没 await async 测试函数中 await func() ,或用 asyncio.run(func())
AttributeError: 'NoneType' object has no attribute 'return_value' mock_obj.method 是 None,因为没正确 patch 方法 确保 mock_obj 是有效的 mock,且 method 是它的一个属性(用 dir(mock_obj) 查看)
pytest.PytestCollectionWarning: cannot collect 'test_' 测试文件名或函数名不符合 pytest 命名规范 文件名改为 test_xxx.py ,函数名改为 test_xxx()

这个表格是我从上千次 CI 失败日志里提炼出来的。你会发现,绝大多数问题都源于“路径错误”或“类型不匹配”。当你下次看到报错,先别急着 Google,对照这张表,90% 的问题能在 30 秒内解决。

6. 我的个人经验:如何让 mocking 成为团队的肌肉记忆

最后分享一点软性经验。技术工具的价值,最终体现在团队协作的流畅度上。在我目前带的团队里,我们用三个简单规则,让 pytest-mock 从“高级技巧”变成“默认操作”。

6.1 规则一:所有测试必须通过 mocker fixture,禁用 @patch 装饰器

我们在 pyproject.toml 里加了一条 lint 规则:

[tool.ruff.lint.select]
# 禁止使用 @patch,强制用 mocker.fixture
extend-select = ["PT001", "PT002"]  # pytest-pytest rules

PT001 对应 @patch PT002 对应 @mock.patch 。CI 流水线一旦检测到,立刻 fail。理由很实在: @patch 的作用域难管理,容易漏掉 stop() ,而 mocker fixture 是 pytest 原生支持的,生命周期 100% 可控。推行这个规则后,mock 相关的 flaky test 减少了 80%。

6.2 规则二:每个 tests/ 目录下必须有 conftest.py ,预置 3 个标准 mock

我们约定每个子模块的 tests/ 目录下, conftest.py 必须包含:

# tests/api/conftest.py
import pytest
from unittest.mock import Mock

@pytest.fixture
def mock_api_client(mocker):
    """预置 API 客户端 mock"""
    client = mocker.Mock()
    client.get.return_value = Mock(status_code=200, json=Mock(return_value={}))
    client.post.return_value = Mock(status_code=201, json=Mock(return_value={}))
    return client

@pytest.fixture
def mock_db_session(mocker):
    """预置 DB session mock"""
    session = mocker.Mock()
    session.add.return_value = None
    session.commit.return_value = None
    session.query.return_value = mocker.Mock()
    return session

@pytest.fixture
def mock_logger(mocker):
    """预置 logger mock"""
    logger = mocker.Mock()
    logger.info.return_value = None
    logger.error.return_value = None
    return logger

新成员入职第一天,就能直接写 def test_something(mock_api_client): ,不用查文档。这降低了 70% 的入门门槛。

6.3 规则三:mock 的“黄金比例”——1 行 mock 配置,3 行业务断言

这是我最看重的实践。一个健康的测试,mock 配置代码不应超过业务断言代码的 1/3。如果一个测试里 mocker.patch 占了 20 行,而 assert 只有 2 行,说明你在 mock 过度,应该重构生产代码,把依赖抽成可注入的参数。

例如,把:

# ❌ 坏:mock 占主导
def test_complex_logic(mocker):
    mock_a = mocker.patch("mod.a")
    mock_b = mocker.patch("mod.b")
    mock_c = mocker.patch("mod.c")
    mock_d = mocker.patch("mod.d")
    mock_e = mocker.patch("mod.e")
    # ... 15 行 mock 配置
    result = complex_function()
    assert result == expected  # 1 行断言

重构为:

# ✅ 好:mock 简洁,逻辑清晰
def test_complex_logic(mocker):
    # 只 mock 关键依赖
    mock_external_service = mocker.Mock()
    mock_external_service.process.return_value = "processed"
    
    # 业务逻辑聚焦
    result = complex_function(external_service=mock_external_service)
    assert result["status"] == "success"
    assert result["data"] == "processed"
    mock_external_service.process.assert_called_once()

这个比例强迫你思考:“这个依赖真的必须 mock 吗?还是我可以把它变成参数,让测试更直接?”久而久之

更多推荐