pytest-mock 实战指南:提升 Python 单元测试效率与可靠性
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_*"]
这个配置做了三件事:
-
addopts里启用了代码覆盖率报告,这对 mock 测试尤其重要——你得确认 mock 的分支逻辑(如if response.status_code == 200)是否被真正覆盖; -
testpaths和python_files明确了测试发现规则,避免 pytest 错误地把tests/conftest.py里的 fixture 当作测试用例执行; -
最关键的是,它让
mockerfixture 在整个测试会话中全局可用 。你不需要在每个test_xxx.py里写from pytest_mock import MockerFixture,只要函数签名里有mocker参数,pytest 就自动注入。
注意:
mockerfixture 的作用域是函数级(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 吗?还是我可以把它变成参数,让测试更直接?”久而久之
更多推荐
所有评论(0)