pytest monkeypatch:模拟函数、模块和运行环境

测试有时依赖全局设置,有时会触发网络访问等不方便直接测试的功能。monkeypatch fixture 可以在测试中设置或删除对象属性、字典项和环境变量,也可以修改导入模块所用的 sys.path。

可用的方法与自动恢复

monkeypatch 提供以下辅助方法:

  • monkeypatch.setattr(obj, name, value, raising=True):替换属性或函数。
  • monkeypatch.delattr(obj, name, raising=True):删除属性。
  • monkeypatch.setitem(mapping, name, value):设置映射中的键值。
  • monkeypatch.delitem(obj, name, raising=True):删除映射中的项。
  • monkeypatch.setenv(name, value, prepend=None):设置环境变量。
  • monkeypatch.delenv(name, raising=True):删除环境变量。
  • monkeypatch.syspath_prepend(path):在模块搜索路径前加入路径。
  • monkeypatch.chdir(path):临时改变当前工作目录。
  • monkeypatch.context():把修改限制在指定作用域内。

请求它的测试函数或 fixture 结束后,所有修改都会撤销。设置或删除目标不存在时,raising 决定是否抛出 KeyError 或 AttributeError。

这些方法适合以下场景:

  1. 测试函数或类属性时,替换其行为。例如不执行真实 API 请求或数据库连接,而让替代函数返回已知数据;也可以暂时删除函数或属性。
  2. 临时改变全局配置字典,用 setitem 设置键值,或用 delitem 删除键。
  3. 设置环境变量,或模拟环境变量缺失。
  4. 用 monkeypatch.setenv("PATH", value, prepend=os.pathsep) 给 PATH 添加前缀,并用 chdir 临时改变工作目录。
  5. 用 syspath_prepend 修改 sys.path;它还会调用 pkg_resources.fixup_namespace_packages 和 importlib.invalidate_caches()。
  6. 用 context 限制补丁的作用范围,便于控制复杂 fixture 或标准库补丁的清理时机。

关于设计动机,还可以阅读 monkeypatch 的介绍文章。

替换函数:固定用户目录

处理用户目录的代码,不应让测试结果依赖实际运行测试的用户。下面把 Path.home 替换为始终返回 Path("/abc") 的函数。

必须先调用 monkeypatch.setattr,再调用使用该函数的代码。测试结束后,Path.home 会恢复:

# contents of test_module.py with source code and the test
from pathlib import Path


def getssh():
    """Simple function to return expanded homedir ssh path."""
    return Path.home() / ".ssh"


def test_getssh(monkeypatch):
    # mocked return function to replace Path.home
    # always return '/abc'
    def mockreturn():
        return Path("/abc")

    # Application of the monkeypatch to replace Path.home
    # with the behavior of mockreturn defined above.
    monkeypatch.setattr(Path, "home", mockreturn)
    # Calling getssh() will use mockreturn in place of Path.home
    # for this test with the monkeypatch.
    x = getssh()
    assert x == Path("/abc/.ssh")

模拟返回的对象

如果测试的函数需要一个具有特定接口的返回对象,可以先构造一个很小的替代类,而不必创建复杂的真实对象。例如下面的函数通过 requests.get 获取响应,再调用其 json() 方法:

# contents of app.py, a simple API retrieval example
import requests


def get_json(url):
    """Takes a URL, and returns the JSON."""
    r = requests.get(url)
    return r.json()

测试用的 MockResponse 只实现所需的 json() 方法。它返回固定字典;mock_get 接受任意参数并返回这个替代对象:

# contents of test_app.py, a simple test for our API retrieval
# import requests for the purposes of monkeypatching
import requests

# our app.py that includes the get_json() function
# this is the previous code block example
import app
# custom class to be the mock return value
# will override the requests.Response returned from requests.get
class MockResponse:
    # mock json() method always returns a specific testing dictionary
    @staticmethod
    def json():
        return {"mock_key": "mock_response"}


def test_get_json(monkeypatch):
    # Any arguments may be passed and mock_get() will always return our
    # mocked object, which only has the .json() method.
    def mock_get(*args, **kwargs):
        return MockResponse()

    # apply the monkeypatch for requests.get to mock_get
    monkeypatch.setattr(requests, "get", mock_get)

    # app.get_json, which contains requests.get, uses the monkeypatch
    result = app.get_json("https://fakeurl")
    assert result["mock_key"] == "mock_response"

monkeypatch 把 requests.get 换成 mock_get。当 app.get_json 调用它时,会拿到模拟对象及其固定 JSON 内容,避免真实网络调用。

同一个替代类可以按需增加 ok 等属性,也可以让 json() 随 URL 改变行为;实现多少接口,取决于被测代码实际使用哪些成员。

为了让多个测试共用这套配置,可以把替换过程移入 fixture:

# contents of test_app.py, a simple test for our API retrieval
import pytest
import requests

# app.py that includes the get_json() function
import app


# custom class to be the mock return value of requests.get()
class MockResponse:
    @staticmethod
    def json():
        return {"mock_key": "mock_response"}


# monkeypatched requests.get moved to a fixture
@pytest.fixture
def mock_response(monkeypatch):
    """Requests.get() mocked to return {'mock_key':'mock_response'}."""

    def mock_get(*args, **kwargs):
        return MockResponse()

    monkeypatch.setattr(requests, "get", mock_get)


# notice our test uses the custom fixture instead of monkeypatch directly
def test_get_json(mock_response):
    result = app.get_json("https://fakeurl")
    assert result["mock_key"] == "mock_response"

测试声明依赖 mock_response,就会在运行前得到该补丁。也可以把 fixture 放到 conftest.py 供多个测试文件使用;如需自动应用于所有相关测试,可声明 autouse=True。

全局禁用 requests 网络请求

如果测试套件不应发出 requests 网络请求,可以在 conftest.py 中使用自动 fixture,删除其 Session 请求方法:

# contents of conftest.py
import pytest


@pytest.fixture(autouse=True)
def no_requests(monkeypatch):
    """Remove requests.sessions.Session.request for all tests."""
    monkeypatch.delattr("requests.sessions.Session.request")

这个 fixture 会为每个测试函数执行,删除 requests.sessions.Session.request。随后通过该方法发起的 HTTP 请求就会失败;它约束的是 requests 的这条调用路径,不能据此断言所有网络库都被禁用。

替换 open、compile 等内置函数可能破坏 pytest 自身,因此不建议这么做。如果无法避免,--tb=native、--assert=plain 和 --capture=no 可能有所帮助,但不能保证正常工作。

标准库以及 pytest 自己依赖的第三方库也有同样的问题。优先替换被测代码真正引用的名字:如果模块写的是 from os import getcwd,应替换 mymodule.getcwd,而不是 os.getcwd。

对能控制的代码,把依赖作为显式参数传入,通常是更稳妥的长期设计。如果必须替换标准库对象,可用 MonkeyPatch.context() 将影响限制在一段代码内:

import functools


def test_partial(monkeypatch):
    with monkeypatch.context() as m:
        m.setattr(functools, "partial", 3)
        assert functools.partial == 3

这个示例离开 with 块后会恢复 functools.partial。相关背景见 pytest issue #3290。

设置和删除环境变量

如果测试依赖环境变量,就需要同时覆盖变量存在和不存在的情形。下面的函数读取 USER 并转为小写;变量缺失时抛出 OSError:

# contents of our original code file e.g. code.py
import os


def get_os_user_lower():
    """Simple retrieval function.
    Returns lowercase USER or raises OSError."""
    username = os.getenv("USER")
    if username is None:
        raise OSError("USER environment is not set.")

    return username.lower()

测试分别使用 setenv 和 delenv。删除时设置 raising=False,使变量原本不存在也不会令准备过程失败:

# contents of our test file e.g. test_code.py
import pytest


def test_upper_to_lower(monkeypatch):
    """Set the USER env var to assert the behavior."""
    monkeypatch.setenv("USER", "TestingUser")
    assert get_os_user_lower() == "testinguser"


def test_raise_exception(monkeypatch):
    """Remove the USER env var and assert OSError is raised."""
    monkeypatch.delenv("USER", raising=False)

    with pytest.raises(OSError):
        _ = get_os_user_lower()

这些是分文件的教学片段;若将函数放入 code.py,测试文件还需从实际模块导入 get_os_user_lower。下面同样保留原文片段,并把环境配置提取成 fixture:

# contents of our test file e.g. test_code.py
import pytest


@pytest.fixture
def mock_env_user(monkeypatch):
    monkeypatch.setenv("USER", "TestingUser")


@pytest.fixture
def mock_env_missing(monkeypatch):
    monkeypatch.delenv("USER", raising=False)


# notice the tests reference the fixtures for mocks
def test_upper_to_lower(mock_env_user):
    assert get_os_user_lower() == "testinguser"


def test_raise_exception(mock_env_missing):
    with pytest.raises(OSError):
        _ = get_os_user_lower()

测试仅需声明所依赖的 fixture,就能使用对应的环境设置;结束后仍会自动恢复。

修改字典配置

可以用 setitem 临时改写字典值,用 delitem 临时删除键。下面的函数用传入的配置生成连接字符串;没有传入配置时使用 DEFAULT_CONFIG:

# contents of app.py to generate a simple connection string
DEFAULT_CONFIG = {"user": "user1", "database": "db1"}


def create_connection_string(config=None):
    """Creates a connection string from input or defaults."""
    config = config or DEFAULT_CONFIG
    return f"User Id={config['user']}; Location={config['database']};"

测试通过两个 setitem 改变默认配置,再检查得到的连接字符串:

# contents of test_app.py
# app.py with the connection string function (prior code block)
import app


def test_connection(monkeypatch):
    # Patch the values of DEFAULT_CONFIG to specific
    # testing values only for this test.
    monkeypatch.setitem(app.DEFAULT_CONFIG, "user", "test_user")
    monkeypatch.setitem(app.DEFAULT_CONFIG, "database", "test_db")
    # expected result based on the mocks
    expected = "User Id=test_user; Location=test_db;"

    # the test uses the monkeypatched dictionary settings
    result = app.create_connection_string()
    assert result == expected

也可以删除必需的 user 键,检查函数是否抛出 KeyError:

# contents of test_app.py
import pytest

# app.py with the connection string function
import app


def test_missing_user(monkeypatch):
    # patch the DEFAULT_CONFIG to be missing the 'user' key
    monkeypatch.delitem(app.DEFAULT_CONFIG, "user", raising=False)

    # Key error expected because a config is not passed, and the
    # default is now missing the 'user' entry.
    with pytest.raises(KeyError):
        _ = app.create_connection_string()

与前面的例子一样,可以把不同修改拆分成独立 fixture。测试只声明需要的部分:

# contents of test_app.py
import pytest

# app.py with the connection string function
import app


# all of the mocks are moved into separated fixtures
@pytest.fixture
def mock_test_user(monkeypatch):
    """Set the DEFAULT_CONFIG user to test_user."""
    monkeypatch.setitem(app.DEFAULT_CONFIG, "user", "test_user")


@pytest.fixture
def mock_test_database(monkeypatch):
    """Set the DEFAULT_CONFIG database to test_db."""
    monkeypatch.setitem(app.DEFAULT_CONFIG, "database", "test_db")


@pytest.fixture
def mock_missing_default_user(monkeypatch):
    """Remove the user key from DEFAULT_CONFIG"""
    monkeypatch.delitem(app.DEFAULT_CONFIG, "user", raising=False)
# tests reference only the fixture mocks that are needed
def test_connection(mock_test_user, mock_test_database):
    expected = "User Id=test_user; Location=test_db;"

    result = app.create_connection_string()
    assert result == expected


def test_missing_user(mock_missing_default_user):
    with pytest.raises(KeyError):
        _ = app.create_connection_string()

API 参考

更详细的行为和参数见 MonkeyPatch API 参考。

来源:pytest 官方文档 How to monkeypatch/mock modules and environments。版权所有 © Holger Krekel、pytest-dev 团队及其他贡献者。中文整理日期:2026-10-03。13 个代码片段保留原文,未运行测试;正文修正了原文介绍网络禁用时的方法路径笔误。

文档及代码依 MIT 许可提供;附 完整许可文本:

MIT 版权与许可
The MIT License (MIT)

Copyright (c) 2004 Holger Krekel and others

Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容