第 10 章 · Mock 测试与 monkeypatch
本章目标:掌握 pytest 内置
monkeypatch的全部 API,学会与unittest.mock(以及pytest-mock的mocker)配合,并建立"何时该 mock、何时不该 mock"的判断力。
10.1 monkeypatch 全家桶
第 9 章预告过:monkeypatch 能安全地替换属性、字典项、环境变量,测试结束自动还原。它的完整方法清单如下(来自官方文档):
| 方法 | 用途 |
|---|---|
setattr(obj, name, value, raising=True) | 替换属性/方法 |
delattr(obj, name, raising=True) | 删除属性/方法 |
setitem(mapping, name, value) / delitem(...) | 修改字典项(如全局配置) |
setenv(name, value) / delenv(name) | 环境变量 |
syspath_prepend(path) | 修改 sys.path(会触发 import 缓存刷新) |
chdir(path) | 切换当前工作目录 |
context() | 返回上下文管理器,把补丁限制在 with 块内 |
所有方法的 raising=True 默认值意味着:目标不存在时会直接报错——这能防止你 patch 一个拼写错误的属性还浑然不觉。
# content of test_env.py
import os
from pathlib import Path
def get_ssh_dir():
return Path.home() / ".ssh"
def test_getssh(monkeypatch):
# 把 Path.home 替换为固定返回值,消除对运行用户的依赖
monkeypatch.setattr(Path, "home", lambda: Path("/abc"))
assert get_ssh_dir() == Path("/abc/.ssh")
# 测试结束后 Path.home 自动恢复原样
def test_missing_config(monkeypatch):
os.environ["API_TOKEN"] = "real-token"
monkeypatch.delenv("API_TOKEN") # 模拟"环境变量缺失"
assert load_token_or_none() is None # 被测代码应走降级分支10.2 用 setattr 替换返回对象:mock 类技巧
被测函数调用链是 requests.get(url) -> response.json() 时,直接替换 requests.get 返回一个假 response 即可。官方示例的做法是为假对象定义一个类:
# content of app.py
import requests
def get_json(url):
"""请求 url 并解析 JSON。"""
r = requests.get(url)
return r.json()# content of test_app.py
import app
import requests
class MockResponse:
@staticmethod
def json():
return {"mock_key": "mock-response"}
def test_get_json(monkeypatch):
monkeypatch.setattr(requests, "get", lambda url: MockResponse())
result = app.get_json("https://fakeurl.com/response/json")
assert result["mock_key"] == "mock-response"patch 谁很关键
要 patch 的是使用方命名空间里的名字。上面 patch requests.get 有效是因为 app.py 写的是 import requests; requests.get(url);如果它写的是 from requests import get,就必须 patch app.get 才能生效——这是 mock 最经典的坑。
monkeypatch.context() 可以把一组补丁限制在局部作用域,适合在同一测试里切换多种环境:
def test_two_modes(monkeypatch):
with monkeypatch.context() as m:
m.setenv("APP_MODE", "debug")
assert read_mode() == "debug"
with monkeypatch.context() as m:
m.setenv("APP_MODE", "prod")
assert read_mode() == "prod"10.3 unittest.mock 与 pytest-mock
monkeypatch 之外,标准库 unittest.mock 提供了更丰富的替身类型。MagicMock 会自动记录所有调用并支持断言:
# content of test_mock.py
from unittest.mock import MagicMock
import app
def test_get_json_with_magicmock(monkeypatch):
fake_response = MagicMock()
fake_response.json.return_value = {"k": "v"} # 配置返回值
monkeypatch.setattr(requests := __import__("requests"), "get",
MagicMock(return_value=fake_response))
assert app.get_json("http://x") == {"k": "v"}
# 还可以断言调用方式是否正确@patch 装饰器是另一种风格,但装饰器会把 mock 作为参数注入,与 pytest fixture 混用时顺序容易出错。社区更流行的方案是 pytest-mock 提供的 mocker fixture——本质是对 mock.patch 的薄封装,用完自动 undo,写法与 monkeypatch 一脉相承:
# pip install pytest-mock
from unittest import mock
def test_fetch(mocker):
mocked = mocker.patch("app.requests.get") # 注意是使用方路径
mocked.return_value.json.return_value = {"ok": True}
from app import get_json
assert get_json("http://x") == {"ok": True}
mocked.assert_called_once_with("http://x") # 断言调用参数三者怎么选?简单规则:
- 改环境变量/字典/工作目录 →
monkeypatch; - 需要断言"被如何调用"(call args、调用次数)→
unittest.mock/mocker; - 大型对象的替身、spec 校验接口 →
MagicMock(spec=...)。
10.4 过度 mock 反模式
mock 是手术刀不是锤子。两个典型反模式:
- mock 到只剩空壳:连被测逻辑本身都被 mock 掉了,测试永远绿但毫无信息量;
- mock 私有实现细节:
patch("service._internal_helper")会让任何重构都炸掉测试——违背第 1 章"测行为不测实现"的原则。
# ❌ 反例:mock 了被测函数自己,等于什么都没测
def test_bad(mocker):
mocker.patch("app.process_order") # 这就是被测函数!
from app import process_order
process_order(1)
process_order.assert_called_once()
# ✅ 正例:只 mock 外部边界(网络),验证真实业务逻辑
def test_good(mocker):
fake_resp = mock.MagicMock()
fake_resp.status_code = 200
mocker.patch("app.requests.get", return_value=fake_resp)
assert handle_webhook("order-1") == "ok" # 业务代码真实执行经验法则:只在架构边界处 mock(HTTP、数据库、消息队列、时钟、随机数);领域逻辑内部让它真实运行。
10.5 本章小结
monkeypatch覆盖 setattr/delattr/setitem/setenv/chdir/syspath/context 九种场景,默认raising=True帮你抓拼写错误;- patch 目标必须是使用方引用的名字,
import x与from x import f的正确 patch 点不同; - 要断言调用行为时用
unittest.mock或pytest-mock的mocker; - 只 mock 架构边界,不 mock 被测逻辑与私有实现。
🧪 随堂测验
点击你认为正确的选项。答错时会展示正确答案与原因解析。
1. app.py 中写了 from requests import get,随后调用 get(url)。要在测试中替换它,正确的 patch 目标是?
2. monkeypatch.setattr(obj, "name", value, raising=True) 中 raising=True 的作用是?
3. 哪个需求最适合用 unittest.mock 而不是单纯 monkeypatch.setattr?
4. 下列哪种做法属于"过度 mock"反模式?
🛠️ 动手实践
- 给一个读取
os.environ["DB_URL"]的配置模块写测试:分别覆盖"存在/缺失/为空"三种情况(提示:setenv+delenv)。 - 用
mocker为一个调用天气 HTTP API 的函数编写测试,要求同时断言返回值解析和请求 URL/参数正确。 - 故意把
patch("app.requests.get")用在一个from requests import get风格的模块上复现"patch 无效"的 bug,再用正确目标修复它,把两组输出记录下来。
下一章研究 pytest 的配置体系——四种配置文件的优先级、rootdir 判定与最常用的 ini 选项:第 11 章。