Python:mock、monkeypatch、fixture 和参数化测试分别适合解决什么问题
mock、monkeypatch、fixture、参数化测试这几个词,经常会在同一篇 pytest 教程里一起出现。
问题在于,它们虽然经常一起用,但并不是一类东西。
如果边界不清楚,测试代码很容易开始变形:
- 明明只是准备输入数据,却上来就 mock
- 明明只想替换一个环境变量,却写了一大段 fake class
- 明明是同一条逻辑的多组输入,却复制了三四条几乎一样的测试
这一篇不按定义分类去背,而是围绕一个实际功能展开:做一个短信通知模块的测试。
这个模块要处理这些场景:
- 给用户发送短信
- 读取环境变量里的签名配置
- 调第三方短信客户端
- 根据不同手机号和内容返回不同结果
用这个场景把四个工具的分工拆开:
- fixture 解决测试前置数据准备
- mock 解决外部依赖替身
- monkeypatch 解决运行时环境或对象属性替换
- 参数化测试解决同一逻辑的多组输入覆盖
一、这篇文章要解决什么问题
读完这一篇,应该能独立完成这些动作:
- 判断一个测试场景到底该用 fixture 还是 mock
- 用 monkeypatch 替换环境变量或模块属性
- 用参数化测试覆盖多组输入,而不是复制测试函数
- 识别几类典型坏味道,例如什么都 mock、什么都塞 fixture、参数化后断言反而更难读
如果这些动作能独立做出来,这几种测试工具就不会再混成一团。
二、先看要测试的实际功能
先把被测逻辑放出来。
notify.py:
1 | import os |
先写一个最小调用:
1 | from notify import send_sms |
输出:
1 | {'ok': True, 'message': 'sent'} |
现在问题来了:测试这个函数时,哪些地方该真实执行,哪些地方该替换?
三、fixture 最适合解决什么问题
fixture 最适合解决的,通常是:
- 测试前置数据
- 重复初始化对象
- 多条测试共享的稳定上下文
1. 先用 fixture 准备基础数据
1 | import pytest |
测试:
1 | from notify import send_sms |
fixture 在这里很清楚:
- 它不负责替代第三方行为
- 它只是把重复输入准备好
一个实际错误
如果只是一个一眼就能看懂的值,也没必要硬抽:
1 | def test_send_sms_success(): |
这条测试本身就很清楚。fixture 不是越多越好。
四、mock 最适合解决什么问题
mock 最适合解决的,通常是:
- 替换第三方依赖
- 控制外部调用返回值
- 验证某个依赖是否按预期被调用
在这个例子里,最明显的外部依赖就是 SmsClient.send()。
1. 先写一个最小 mock 测试
1 | from unittest.mock import Mock |
2. 再验证调用参数
1 | def test_send_sms_calls_client_with_expected_args(): |
如果想继续看参数:
1 | fake_client.send.assert_called_once_with("13800000000", "hello", "DEFAULT") |
一个实际错误
错误方向通常是:连纯函数内部逻辑也一起 mock 掉。
如果把 send_sms() 里自己的判断逻辑都替掉,那测试就失去意义了。
mock 更适合替“外部依赖”,不适合替“当前要验证的核心逻辑”。
五、monkeypatch 最适合解决什么问题
monkeypatch 和 mock 经常一起出现,但最适合的场景不完全一样。
monkeypatch 更适合:
- 改环境变量
- 改模块级属性
- 临时替换函数或对象属性
1. 先替换环境变量
1 | from unittest.mock import Mock |
这里 monkeypatch 的价值很直接:
- 当前测试里临时改环境
- 测试结束后自动恢复
2. 再看一个属性替换例子
1 | def test_send_sms_override_client_send(monkeypatch): |
一个实际错误
有些场景其实只需要传入假对象,不一定非要 monkeypatch 全局对象。
如果函数本身已经支持依赖注入:
1 | send_sms(..., client=fake_client) |
那通常优先直接传 fake client,会比 monkeypatch 某个全局类更清楚。
六、参数化测试最适合解决什么问题
参数化测试最适合的,通常是:
- 同一条逻辑
- 多组输入
- 预期输出不同,但断言结构一致
在这个例子里,手机号校验就很适合参数化。
1. 先写一个最小参数化测试
1 | import pytest |
执行:
1 | pytest -q |
可能输出:
1 | .... [100%] |
一个实际错误
错误方向通常是:参数化之后,把每组数据背后的意义写丢了。
例如:
1 |
虽然短,但可读性很差。
如果参数化数据已经开始让读者看不出场景,通常应该:
- 把测试数据写得更完整
- 或者拆成两三条更明确的测试
参数化不是为了把测试写得最短,而是为了减少重复的同时不丢场景含义。
七、把四种工具真正串起来
现在把 fixture、mock、monkeypatch、参数化放到同一个测试文件里。
test_notify.py:
1 | from unittest.mock import Mock |
这个版本里四者的边界就比较清楚了:
- fixture:准备稳定输入
- mock:替代外部 client
- monkeypatch:改环境变量
- 参数化:覆盖同一逻辑的多组输入
八、什么时候四者会被用错
最常见的错法大概有四类:
1. fixture 过度封装
测试里只看到:
1 | def test_xxx(user_fixture, env_fixture, order_fixture, client_fixture): |
但不知道当前场景到底是什么。
2. mock 用来替当前正在测试的核心逻辑
这会让测试失去意义。
3. monkeypatch 明明只改一个输入,却把整段全局依赖都改了
这种写法会让测试读起来很重。
4. 参数化测试塞太多维度
例如一条测试同时参数化:
- 手机号
- 短信内容
- 返回状态
- 环境变量
这会让失败时很难一眼看出是哪个维度出了问题。
九、一个实际排错场景
这类测试里一个非常常见的实际问题是:单独跑 test_send_sms_uses_env_sign_name 能过,整文件一起跑时,别的测试也莫名其妙带上了 TEST_SIGN。
这时排查顺序通常很直接:
- 先看有没有直接改
os.environ - 再看是不是用了
monkeypatch.setenv() - 再看测试结束后环境是否自动恢复
如果最后发现写法是:
1 | import os |
那问题通常就不是 pytest 不稳定,而是环境修改没有被隔离。
十、一个实际练习
可以直接把这一篇变成一个完整练习。
练习目标:给一个“邮件发送模块”补测试。
要求:
- 用 fixture 准备基础邮箱和正文
- 用 mock 替换邮件客户端
- 用 monkeypatch 改发件人配置
- 用参数化测试覆盖多个邮箱格式场景
- 至少补一个实际错误场景,例如过度 mock 或直接改全局环境变量
如果这个练习能独立做完,说明这四种工具的分工已经开始真正掌握。
十一、这篇文章学完以后,下一步应该补什么
如果这一篇已经能跟着做完,下一步最适合继续补的是:
- Python 性能优化里最值得先看的部分
- 数据结构与算法怎么和真实工程问题连接起来
- 更复杂的测试目录和测试数据如何继续组织
因为到这一步,测试工具箱已经开始有层次了,接下来更重要的是知道什么时候该拿哪个工具,而不是全部混在一起。
十二、结语
mock、monkeypatch、fixture、参数化测试经常一起出现,但它们解决的不是同一个问题。
只要把分工守住:
- fixture 准备上下文
- mock 替外部依赖
- monkeypatch 改运行环境
- 参数化覆盖多组输入
测试代码就会清楚很多。反过来,只要边界混了,测试就会很快变成“什么都能写,但越来越难读”。