如何修补/模拟模块和环境

有时测试需要调用依赖于全局设置或调用难以测试的代码(例如网络访问)的功能。monkeypatch 固定装置可帮助您安全地设置/删除属性、字典项或环境变量,或修改 sys.path 以便导入。

monkeypatch 固定装置提供以下帮助程序方法,用于在测试中安全地修补和模拟功能

请求的测试函数或固定装置完成后,所有修改都将撤销。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 仅在特定范围内应用修补程序,这有助于控制复杂夹具或对 stdlib 的修补程序的拆除。

请参阅 monkeypatch 博客文章,了解一些介绍材料及其动机讨论。

monkeypatching 函数

考虑一个正在处理用户目录的场景。在测试上下文中,您不希望测试依赖于正在运行的用户。 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():
    """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")

monkeypatching 返回的对象:构建模拟类

monkeypatch.setattr 可与类结合使用,以模拟函数返回的对象,而不是值。想象一个简单的函数,它获取 API URL 并返回 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()

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

# 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 使用我们的 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

# 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"

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

全局修补示例:防止“requests”进行远程操作

如果您想防止“requests”库在所有测试中执行 http 请求,您可以执行

# 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")

此 autouse fixture 将为每个测试函数执行,它将删除方法 request.session.Session.request,以便在测试中创建 http 请求的任何尝试都将失败。

注意

建议不要修补诸如 opencompile 等内置函数,因为它可能会破坏 pytest 的内部。如果这是不可避免的,则传递 --tb=native--assert=plain--capture=no 可能会提供帮助,尽管无法保证。

注意

请注意,修补 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

有关详细信息,请参阅 issue #3290

MonkeyPatching 环境变量

如果您使用环境变量,则通常需要出于测试目的安全地更改其值或从系统中删除它们。 monkeypatch 提供了一种使用 setenvdelenv 方法来实现此目的的机制。我们的示例代码用于测试

# 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()

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

# 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()

此行为可以移入 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()

MonkeyPatching 字典

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

# 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']};"

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

# 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

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

# 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 t 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()

固定装置的模块化使您可以灵活地为每个潜在的模拟定义单独的固定装置,并在所需的测试中引用它们。

# 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 类的文档。