Python:mock、monkeypatch、fixture 和参数化测试分别适合解决什么问题

mock、monkeypatch、fixture、参数化测试这几个词,经常会在同一篇 pytest 教程里一起出现。

问题在于,它们虽然经常一起用,但并不是一类东西。

如果边界不清楚,测试代码很容易开始变形:

  • 明明只是准备输入数据,却上来就 mock
  • 明明只想替换一个环境变量,却写了一大段 fake class
  • 明明是同一条逻辑的多组输入,却复制了三四条几乎一样的测试

这一篇不按定义分类去背,而是围绕一个实际功能展开:做一个短信通知模块的测试

这个模块要处理这些场景:

  1. 给用户发送短信
  2. 读取环境变量里的签名配置
  3. 调第三方短信客户端
  4. 根据不同手机号和内容返回不同结果

用这个场景把四个工具的分工拆开:

  1. fixture 解决测试前置数据准备
  2. mock 解决外部依赖替身
  3. monkeypatch 解决运行时环境或对象属性替换
  4. 参数化测试解决同一逻辑的多组输入覆盖

一、这篇文章要解决什么问题

读完这一篇,应该能独立完成这些动作:

  • 判断一个测试场景到底该用 fixture 还是 mock
  • 用 monkeypatch 替换环境变量或模块属性
  • 用参数化测试覆盖多组输入,而不是复制测试函数
  • 识别几类典型坏味道,例如什么都 mock、什么都塞 fixture、参数化后断言反而更难读

如果这些动作能独立做出来,这几种测试工具就不会再混成一团。

二、先看要测试的实际功能

先把被测逻辑放出来。

notify.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import os


class SmsClient:
def send(self, phone, content, sign_name):
return {"ok": True, "message": "sent"}


def send_sms(phone, content, client=None):
if client is None:
client = SmsClient()

if not phone.startswith("1"):
return {"ok": False, "message": "invalid phone"}

sign_name = os.getenv("SMS_SIGN_NAME", "DEFAULT")
return client.send(phone, content, sign_name)

先写一个最小调用:

1
2
3
4
5
from notify import send_sms


result = send_sms("13800000000", "hello")
print(result)

输出:

1
{'ok': True, 'message': 'sent'}

现在问题来了:测试这个函数时,哪些地方该真实执行,哪些地方该替换?

三、fixture 最适合解决什么问题

fixture 最适合解决的,通常是:

  • 测试前置数据
  • 重复初始化对象
  • 多条测试共享的稳定上下文

1. 先用 fixture 准备基础数据

1
2
3
4
5
6
7
8
9
10
11
import pytest


@pytest.fixture
def valid_phone():
return "13800000000"


@pytest.fixture
def message_text():
return "登录验证码 123456"

测试:

1
2
3
4
5
6
from notify import send_sms


def test_send_sms_success(valid_phone, message_text):
result = send_sms(valid_phone, message_text)
assert result["ok"] is True

fixture 在这里很清楚:

  • 它不负责替代第三方行为
  • 它只是把重复输入准备好

一个实际错误

如果只是一个一眼就能看懂的值,也没必要硬抽:

1
2
3
def test_send_sms_success():
result = send_sms("13800000000", "登录验证码 123456")
assert result["ok"] is True

这条测试本身就很清楚。fixture 不是越多越好。

四、mock 最适合解决什么问题

mock 最适合解决的,通常是:

  • 替换第三方依赖
  • 控制外部调用返回值
  • 验证某个依赖是否按预期被调用

在这个例子里,最明显的外部依赖就是 SmsClient.send()

1. 先写一个最小 mock 测试

1
2
3
4
5
6
7
8
9
10
11
12
13
from unittest.mock import Mock

from notify import send_sms


def test_send_sms_success_with_mock():
fake_client = Mock()
fake_client.send.return_value = {"ok": True, "message": "mock sent"}

result = send_sms("13800000000", "hello", client=fake_client)

assert result["ok"] is True
assert result["message"] == "mock sent"

2. 再验证调用参数

1
2
3
4
5
6
7
def test_send_sms_calls_client_with_expected_args():
fake_client = Mock()
fake_client.send.return_value = {"ok": True, "message": "mock sent"}

send_sms("13800000000", "hello", client=fake_client)

fake_client.send.assert_called_once()

如果想继续看参数:

1
fake_client.send.assert_called_once_with("13800000000", "hello", "DEFAULT")

一个实际错误

错误方向通常是:连纯函数内部逻辑也一起 mock 掉。

如果把 send_sms() 里自己的判断逻辑都替掉,那测试就失去意义了。

mock 更适合替“外部依赖”,不适合替“当前要验证的核心逻辑”。

五、monkeypatch 最适合解决什么问题

monkeypatch 和 mock 经常一起出现,但最适合的场景不完全一样。

monkeypatch 更适合:

  • 改环境变量
  • 改模块级属性
  • 临时替换函数或对象属性

1. 先替换环境变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from unittest.mock import Mock

from notify import send_sms


def test_send_sms_uses_env_sign_name(monkeypatch):
monkeypatch.setenv("SMS_SIGN_NAME", "TEST_SIGN")

fake_client = Mock()
fake_client.send.return_value = {"ok": True, "message": "sent"}

send_sms("13800000000", "hello", client=fake_client)

fake_client.send.assert_called_once_with("13800000000", "hello", "TEST_SIGN")

这里 monkeypatch 的价值很直接:

  • 当前测试里临时改环境
  • 测试结束后自动恢复

2. 再看一个属性替换例子

1
2
3
4
5
6
7
def test_send_sms_override_client_send(monkeypatch):
class FakeClient:
def send(self, phone, content, sign_name):
return {"ok": False, "message": "network down"}

result = send_sms("13800000000", "hello", client=FakeClient())
assert result["message"] == "network down"

一个实际错误

有些场景其实只需要传入假对象,不一定非要 monkeypatch 全局对象。

如果函数本身已经支持依赖注入:

1
send_sms(..., client=fake_client)

那通常优先直接传 fake client,会比 monkeypatch 某个全局类更清楚。

六、参数化测试最适合解决什么问题

参数化测试最适合的,通常是:

  • 同一条逻辑
  • 多组输入
  • 预期输出不同,但断言结构一致

在这个例子里,手机号校验就很适合参数化。

1. 先写一个最小参数化测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import pytest

from notify import send_sms


@pytest.mark.parametrize(
"phone, expected_ok",
[
("13800000000", True),
("18812345678", True),
("abc", False),
("23800000000", False),
],
)
def test_send_sms_phone_validation(phone, expected_ok):
result = send_sms(phone, "hello")
assert result["ok"] is expected_ok

执行:

1
pytest -q

可能输出:

1
2
....                                                                 [100%]
4 passed in 0.03s

一个实际错误

错误方向通常是:参数化之后,把每组数据背后的意义写丢了。

例如:

1
@pytest.mark.parametrize("phone, expected_ok", [("1", True), ("2", False)])

虽然短,但可读性很差。

如果参数化数据已经开始让读者看不出场景,通常应该:

  • 把测试数据写得更完整
  • 或者拆成两三条更明确的测试

参数化不是为了把测试写得最短,而是为了减少重复的同时不丢场景含义。

七、把四种工具真正串起来

现在把 fixture、mock、monkeypatch、参数化放到同一个测试文件里。

test_notify.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
from unittest.mock import Mock

import pytest

from notify import send_sms


@pytest.fixture
def valid_phone():
return "13800000000"


@pytest.fixture
def message_text():
return "登录验证码 123456"


def test_send_sms_success_with_mock(valid_phone, message_text):
fake_client = Mock()
fake_client.send.return_value = {"ok": True, "message": "mock sent"}

result = send_sms(valid_phone, message_text, client=fake_client)

assert result["ok"] is True
assert result["message"] == "mock sent"


def test_send_sms_uses_env_sign_name(monkeypatch, valid_phone, message_text):
monkeypatch.setenv("SMS_SIGN_NAME", "TEST_SIGN")

fake_client = Mock()
fake_client.send.return_value = {"ok": True, "message": "sent"}

send_sms(valid_phone, message_text, client=fake_client)

fake_client.send.assert_called_once_with(valid_phone, message_text, "TEST_SIGN")


@pytest.mark.parametrize(
"phone, expected_ok",
[
("13800000000", True),
("18812345678", True),
("abc", False),
("23800000000", False),
],
)
def test_send_sms_phone_validation(phone, expected_ok):
result = send_sms(phone, "hello")
assert result["ok"] is expected_ok

这个版本里四者的边界就比较清楚了:

  • fixture:准备稳定输入
  • mock:替代外部 client
  • monkeypatch:改环境变量
  • 参数化:覆盖同一逻辑的多组输入

八、什么时候四者会被用错

最常见的错法大概有四类:

1. fixture 过度封装

测试里只看到:

1
2
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

这时排查顺序通常很直接:

  1. 先看有没有直接改 os.environ
  2. 再看是不是用了 monkeypatch.setenv()
  3. 再看测试结束后环境是否自动恢复

如果最后发现写法是:

1
2
import os
os.environ["SMS_SIGN_NAME"] = "TEST_SIGN"

那问题通常就不是 pytest 不稳定,而是环境修改没有被隔离。

十、一个实际练习

可以直接把这一篇变成一个完整练习。

练习目标:给一个“邮件发送模块”补测试。

要求:

  1. 用 fixture 准备基础邮箱和正文
  2. 用 mock 替换邮件客户端
  3. 用 monkeypatch 改发件人配置
  4. 用参数化测试覆盖多个邮箱格式场景
  5. 至少补一个实际错误场景,例如过度 mock 或直接改全局环境变量

如果这个练习能独立做完,说明这四种工具的分工已经开始真正掌握。

十一、这篇文章学完以后,下一步应该补什么

如果这一篇已经能跟着做完,下一步最适合继续补的是:

  1. Python 性能优化里最值得先看的部分
  2. 数据结构与算法怎么和真实工程问题连接起来
  3. 更复杂的测试目录和测试数据如何继续组织

因为到这一步,测试工具箱已经开始有层次了,接下来更重要的是知道什么时候该拿哪个工具,而不是全部混在一起。

十二、结语

mock、monkeypatch、fixture、参数化测试经常一起出现,但它们解决的不是同一个问题。

只要把分工守住:

  • fixture 准备上下文
  • mock 替外部依赖
  • monkeypatch 改运行环境
  • 参数化覆盖多组输入

测试代码就会清楚很多。反过来,只要边界混了,测试就会很快变成“什么都能写,但越来越难读”。