如何使用 猴子补丁/mock 模块和环境

How to monkeypatch/mock modules and environments

有时,测试需要调用依赖于全局设置的功能,或者调用一些无法轻易测试的代码,例如网络访问。 monkeypatch fixture 可以帮助您安全地设置/删除属性、字典项或环境变量,或修改 sys.path 以进行导入。

monkeypatch fixture 提供了以下辅助方法,用于在测试中安全地修补和模拟功能:

所有修改将在请求的测试函数或fixture 完成后撤销。 raising 参数决定如果设置/删除操作的目标不存在时,是否会引发 KeyErrorAttributeError

考虑以下场景:

  1. 修改函数的行为或类的属性进行测试,例如,您不会为测试进行 API 调用或数据库连接,但您知道预期的输出应该是什么。使用 monkeypatch.setattr 来修补函数或属性以实现您所需的测试行为。这可以包括您自己的函数。使用 monkeypatch.delattr 在测试中删除该函数或属性。

  2. 修改字典的值,例如,您有一个全局配置,希望为某些测试用例进行修改。使用 monkeypatch.setitem 来修补字典以进行测试。可以使用 monkeypatch.delitem 来删除项目。

  3. 修改环境变量进行测试,例如,测试程序行为在缺少某个环境变量时,或者将多个值设置为已知变量。可以使用 monkeypatch.setenvmonkeypatch.delenv 进行这些修补。

  4. 使用 monkeypatch.setenv("PATH", value, prepend=os.pathsep) 来修改 $PATH,并使用 monkeypatch.chdir 在测试期间更改当前工作目录的上下文。

  5. 使用 monkeypatch.syspath_prepend 来修改 sys.path,这也将调用 pkg_resources.fixup_namespace_packagesimportlib.invalidate_caches()

  6. 使用 monkeypatch.context 仅在特定范围内应用修补,这可以帮助控制复杂fixture 或标准库的修补的拆卸。

请参见 monkeypatch blog post 以获取一些介绍材料和其动机的讨论。

猴子补丁函数

Monkeypatching functions

考虑一个场景,您正在处理用户目录。在测试的上下文中,您不希望测试依赖于运行用户。 monkeypatch 可以用来修补依赖于用户的函数,以始终返回一个特定值。

在这个例子中,使用 monkeypatch.setattr 来修补 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():
    """简单函数返回扩展的主目录 ssh 路径。"""
    return Path.home() / ".ssh"


def test_getssh(monkeypatch):
    # 模拟的返回函数来替换 Path.home
    # 始终返回 '/abc'
    def mockreturn():
        return Path("/abc")

    # 应用 monkeypatch 来将 Path.home 替换为
    # 上面定义的 mockreturn 的行为。
    monkeypatch.setattr(Path, "home", mockreturn)

    # 调用 getssh() 将在此测试中使用 mockreturn
    # 替代 Path.home。
    x = getssh()
    assert x == Path("/abc/.ssh")

猴子补丁返回对象: 构建mock类

Monkeypatching returned objects: building mock classes

monkeypatch.setattr 可以与类一起使用,以模拟函数返回的对象,而不是值。想象一个简单的函数,它接受一个 API URL 并返回 JSON 响应。

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


def get_json(url):
    """接受一个 URL,并返回 JSON。"""
    r = requests.get(url)
    return r.json()

我们需要模拟 r,即返回的响应对象,以便于测试。对 r 的模拟需要一个 .json() 方法,返回一个字典。这可以在我们的测试文件中通过定义一个类来表示 r

# contents of test_app.py, a simple test for our API retrieval
# 为了进行 monkeypatching 导入 requests
import requests

# 包含 get_json() 函数的 app.py
# 这是之前代码块的示例
import app


# 自定义类作为模拟返回值
# 将覆盖 requests.get 返回的 requests.Response
class MockResponse:
    # 模拟的 json() 方法始终返回一个特定的测试字典
    @staticmethod
    def json():
        return {"mock_key": "mock_response"}


def test_get_json(monkeypatch):
    # 任何参数都可以传入,mock_get() 将始终返回我们的
    # 模拟对象,该对象仅具有 .json() 方法。
    def mock_get(*args, **kwargs):
        return MockResponse()

    # 将 monkeypatch 应用于 requests.get,替换为 mock_get
    monkeypatch.setattr(requests, "get", mock_get)

    # app.get_json,其中包含 requests.get,使用了 monkeypatch
    result = app.get_json("https://fakeurl")
    assert result["mock_key"] == "mock_response"

monkeypatch 用我们的 mock_get 函数对 requests.get 进行了模拟。 mock_get 函数返回 MockResponse 类的一个实例,该类定义了一个 json() 方法,以返回一个已知的测试字典,并且不需要任何外部 API 连接。

您可以根据要测试的场景构建适当复杂度的 MockResponse 类。例如,它可以包含一个始终返回 Trueok 属性,或者根据输入字符串从模拟的 json() 方法返回不同的值。

这个模拟可以通过 fixture 在多个测试之间共享:

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

# 包含 get_json() 函数的 app.py
import app


# 自定义类作为 requests.get() 的模拟返回值
class MockResponse:
    @staticmethod
    def json():
        return {"mock_key": "mock_response"}


# monkeypatched 的 requests.get 移动到 fixture
@pytest.fixture
def mock_response(monkeypatch):
    """Requests.get() 模拟返回 {'mock_key':'mock_response'}。"""

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

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


# 注意我们的测试使用了自定义 fixture,而不是直接使用 monkeypatch
def test_get_json(mock_response):
    result = app.get_json("https://fakeurl")
    assert result["mock_key"] == "mock_response"

此外,如果模拟被设计为应用于所有测试,则可以将 fixture 移动到 conftest.py 文件中,并使用 autouse=True 选项。

全局补丁示例:阻止远程操作的“请求”

Global patch example: preventing “requests” from remote operations

如果您想防止 “requests” 库在所有测试中执行 HTTP 请求,可以这样做:

# contents of conftest.py
import pytest


@pytest.fixture(autouse=True)
def no_requests(monkeypatch):
    """为所有测试移除 requests.sessions.Session.request。"""
    monkeypatch.delattr("requests.sessions.Session.request")

这个自动使用的 fixture 将在每个测试函数中执行,它将删除方法 request.session.Session.request,因此测试中任何尝试创建 HTTP 请求的操作都将失败。

Note

请注意,不建议修改内置函数,例如 opencompile 等,因为这可能会破坏 pytest 的内部功能。如果这是不可避免的,传递 --tb=native--assert=plain--capture=no 可能会有所帮助,但不能保证有效。

Note

请注意,修改 stdlib 函数和 pytest 使用的一些第三方库可能会破坏 pytest 本身,因此在这些情况下,建议使用 MonkeyPatch.context() 将补丁限制在您想要测试的块中:

import functools


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

有关详细信息,请参见 #3290

猴子补丁环境变量

Monkeypatching environment variables

如果您正在处理环境变量,通常需要安全地更改值或从系统中删除它们以进行测试。monkeypatch 提供了一种机制来实现这一点,使用 setenvdelenv 方法。我们的示例代码如下:

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


def get_os_user_lower():
    """简单的检索函数。
    返回小写的 USER,或引发 OSError。"""
    username = os.getenv("USER")

    if username is None:
        raise OSError("USER 环境变量未设置。")

    return username.lower()

这里有两个潜在的路径。首先,USER 环境变量被设置为一个值。其次,USER 环境变量不存在。使用 monkeypatch 可以安全地测试这两条路径,而不会影响运行环境:

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


def test_upper_to_lower(monkeypatch):
    """设置 USER 环境变量以断言行为。"""
    monkeypatch.setenv("USER", "TestingUser")
    assert get_os_user_lower() == "testinguser"


def test_raise_exception(monkeypatch):
    """移除 USER 环境变量并断言引发 OSError。"""
    monkeypatch.delenv("USER", raising=False)

    with pytest.raises(OSError):
        _ = 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)


# 注意测试引用了用于模拟的 fixtures
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()

猴子补丁参考

Monkeypatching dictionaries

monkeypatch.setitem 可以在测试期间安全地设置字典的值为特定值。以下是一个简化的连接字符串示例:

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


def create_connection_string(config=None):
    """根据输入或默认值创建连接字符串。"""
    config = config or DEFAULT_CONFIG
    return f"User Id={config['user']}; Location={config['database']};"

出于测试目的,我们可以将 DEFAULT_CONFIG 字典打补丁,设置为特定值。

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


def test_connection(monkeypatch):
    # 将 DEFAULT_CONFIG 的值打补丁为特定的
    # 测试值,仅在此测试中有效。
    monkeypatch.setitem(app.DEFAULT_CONFIG, "user", "test_user")
    monkeypatch.setitem(app.DEFAULT_CONFIG, "database", "test_db")

    # 基于模拟的预期结果
    expected = "User Id=test_user; Location=test_db;"

    # 测试使用了猴子补丁的字典设置
    result = app.create_connection_string()
    assert result == expected

您可以使用 monkeypatch.delitem 来删除值。

# contents of test_app.py
import pytest

# app.py with the connection string function
import app


def test_missing_user(monkeypatch):
    # 打补丁使 DEFAULT_CONFIG 缺少 'user' 键
    monkeypatch.delitem(app.DEFAULT_CONFIG, "user", raising=False)

    # 预期引发 KeyError,因为未传递配置,且
    # 默认值现在缺少 'user' 条目。
    with pytest.raises(KeyError):
        _ = app.create_connection_string()

fixture 的模块化为您提供了定义 每个潜在模拟的独立 fixture 的灵活性,并在所需的测试中引用它们。

# contents of test_app.py
import pytest

# app.py with the connection string function
import app


# 所有模拟均移入独立的 fixture 中
@pytest.fixture
def mock_test_user(monkeypatch):
    """将 DEFAULT_CONFIG 的用户设置为 test_user。"""
    monkeypatch.setitem(app.DEFAULT_CONFIG, "user", "test_user")


@pytest.fixture
def mock_test_database(monkeypatch):
    """将 DEFAULT_CONFIG 的数据库设置为 test_db。"""
    monkeypatch.setitem(app.DEFAULT_CONFIG, "database", "test_db")


@pytest.fixture
def mock_missing_default_user(monkeypatch):
    """从 DEFAULT_CONFIG 中移除用户键。"""
    monkeypatch.delitem(app.DEFAULT_CONFIG, "user", raising=False)


# 测试仅引用所需的 fixture 模拟
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 参考

API Reference

查阅 MonkeyPatch 类的文档。