Skip to content

第 10 章 · Mock 测试与 monkeypatch

本章目标:掌握 pytest 内置 monkeypatch 的全部 API,学会与 unittest.mock(以及 pytest-mockmocker)配合,并建立"何时该 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 一个拼写错误的属性还浑然不觉。

python
# 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 即可。官方示例的做法是为假对象定义一个类:

python
# content of app.py
import requests


def get_json(url):
    """请求 url 并解析 JSON。"""
    r = requests.get(url)
    return r.json()
python
# 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() 可以把一组补丁限制在局部作用域,适合在同一测试里切换多种环境:

python
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 会自动记录所有调用并支持断言:

python
# 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 一脉相承:

python
# 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 是手术刀不是锤子。两个典型反模式:

  1. mock 到只剩空壳:连被测逻辑本身都被 mock 掉了,测试永远绿但毫无信息量;
  2. mock 私有实现细节patch("service._internal_helper") 会让任何重构都炸掉测试——违背第 1 章"测行为不测实现"的原则。
python
# ❌ 反例: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 xfrom x import f 的正确 patch 点不同;
  • 要断言调用行为时用 unittest.mockpytest-mockmocker
  • 只 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"反模式?

🛠️ 动手实践

  1. 给一个读取 os.environ["DB_URL"] 的配置模块写测试:分别覆盖"存在/缺失/为空"三种情况(提示:setenv + delenv)。
  2. mocker 为一个调用天气 HTTP API 的函数编写测试,要求同时断言返回值解析和请求 URL/参数正确。
  3. 故意把 patch("app.requests.get") 用在一个 from requests import get 风格的模块上复现"patch 无效"的 bug,再用正确目标修复它,把两组输出记录下来。

下一章研究 pytest 的配置体系——四种配置文件的优先级、rootdir 判定与最常用的 ini 选项:第 11 章