pytest06 MonkeyPatching/Mocking module/environment

这个 monkeypatch fixture为测试中的安全修补和模拟功能提供了以下帮助方法:

1
2
3
4
5
6
7
8
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=False)
monkeypatch.delenv(name, raising=True)
monkeypatch.syspath_prepend(path)
monkeypatch.chdir(path)

所有修改将在请求的测试功能或固件完成后撤消。如果设置/删除操作的目标不存在,这个 raising 参数决定了是否抛出 KeyErrorAttributeError错误。

考虑以下情况:

  1. 为测试修改函数的行为或类的属性,例如有一个API调用或数据库连接,你将无法进行测试,但你知道预期的输出应该是什么。使用 monkeypatch.setattr() 使用所需的测试行为修补函数或属性,这可以包括你自己的功能,使用 monkeypatch.delattr() 删除测试的函数或属性。

  2. 修改字典的值,例如,对于某些测试用例,你需要修改全局配置。使用 monkeypatch.setitem()为测试修改字典。 monkeypatch.delitem() 可用于删除项目。

  3. 修改测试的环境变量,例如在缺少环境变量时测试程序行为,或将多个值设置为已知变量。 monkeypatch.setenv()monkeypatch.delenv() 可用于这些修补。

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

  5. 使用py:meth: monkeypatch.syspath_prepend 来修改sys.path,这将会调用pkg_resources.fixup_namespace_packages()` and `importlib.invalidate_caches()

简单示例:monkeypatching 函数

考虑使用用户目录的场景,在测试环境中,你不希望测试依赖于正在运行的用户。 monkeypatch 可用于修补依赖于用户的函数,以始终返回特定值。

在这个例子中, monkeypatch.setattr() 用于修补 Path.home 使已知的测试路径 Path("/abc") 总是在运行测试时使用。这将删除出于测试目的对正在运行的用户的任何依赖。 monkeypatch.setattr() 必须在调用将使用修补函数的函数之前调用。测试功能完成后, Path.home 修改将被撤消。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 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")

定义 mockreturn 函数

1
2
def mockreturn():
return Path("/abc")
  • mockreturn 是一个模拟函数,用于替换 Path.home() 的行为,使其始终返回 Path("/abc"),即一个虚拟的路径 /abc

使用 monkeypatch 替换 Path.home 方法

1
monkeypatch.setattr(Path, "home", mockreturn)
  • monkeypatch.setattr 用于在测试期间临时替换 Path.home 方法,使其行为变为 mockreturn
  • 这样,在 test_getssh 测试中,调用 Path.home() 就会返回 /abc,而不是实际的主目录路径。

调用 getssh 并断言结果

1
2
x = getssh()
assert x == Path("/abc/.ssh")
  • 调用 getssh() 时,由于 Path.home() 已被替换为 mockreturn,所以 getssh 会返回 Path("/abc") / ".ssh",即 Path("/abc/.ssh")
  • assert 语句用于验证 getssh() 的返回值是否为 Path("/abc/.ssh")。如果返回值匹配,则测试通过;否则测试失败。

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

monkeypatch.setattr() 可以与类一起使用,模拟从函数而不是值返回的对象。设想一个简单的函数获取一个API URL并返回JSON响应。我们需要模拟r ,返回的响应对象用于测试目的。 r 需要一个 .json() 返回字典的方法。这可以在我们的测试文件中通过定义一个类来表示 r .

1
2
3
4
5
6
7
8
# app.py
import requests


def get_json(url):
"""Takes a URL, and returns the JSON."""
r = requests.get(url)
return r.json()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# test_mock_response.py
import pytest
import requests
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"

monkeypatchmock_get 功能模拟应用于 requests.get ,这个 mock_get 函数返回 MockResponse 类,其中有一个 json() 方法定义为返回已知的测试字典,不需要任何外部API连接, 调用 app.get_json("https://fakeurl") 时,不会实际发送请求。

可以建立 MockResponse 为你正在测试的场景使用适当的复杂性来初始化。例如,它可以包括始终返回 True ok属性 或者基于输入字符串返回不同的值 json() 的模拟方法。

小结,使用 MockResponse 模拟返回对象:通过自定义 MockResponse 类模拟 HTTP 响应,以控制测试环境中的数据。**monkeypatch 替换 requests.get**:通过 monkeypatchrequests.get 替换为 mock_get,实现对外部依赖的隔离。

此外,如果模拟模型设计用于所有测试,则 fixture 可以移动到 conftest.py 归档并与一起使用 autouse=True 选择项。


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

如果要阻止“请求”库在所有测试中执行HTTP请求,可以执行以下操作:

1
2
3
4
5
6
7
# 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 可能会有帮助,尽管没有保证。

修补 Python 内置函数(如 opencompile 等)或 Pytest 依赖的函数可能会导致 Pytest 及其功能(如断言和捕获输出)异常。因为 Pytest 本身依赖于这些函数,过度修补会干扰其正常运行。如果必须修补,可以尝试通过添加 --tb=native--assert=plain--capture=no 参数来减轻影响,不过这些参数并不总能保证避免问题。

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

1
2
3
4
5
6
import functools

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

使用 monkeypatch.context() 可以将修补作用域限制在 with 语句内的代码块,确保修补仅在该代码块内有效,代码块结束后修补会自动撤销。

这是对标准库函数、Pytest 依赖的函数或第三方库函数进行修补的更安全方式,因为它最小化了修补的作用范围,降低了对 Pytest 或系统行为的干扰风险。


MonkeyPatching 环境变量

如果你正在使用环境变量,为了测试的目的你需要安全地更改这些值或从系统中删除它们, monkeypatch 提供了一种使用 setenvdelenv 机制,我们要测试的示例代码:

1
2
3
4
5
6
7
8
9
10
11
import os

def get_os_user_lower():
"""
Returns lowercase USER or raises EnvironmentError."""
username = os.getenv("USER")

if username is None:
raise EnvironmentError("USER environment is not set.")

return username.lower()

有两条可能的路径。首先, USER 环境变量设置为值,其次, USER 环境变量不存在。使用 monkeypatch 两条路径都可以在不影响运行环境的情况下进行安全测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 EnvironmentError is raised."""
monkeypatch.delenv("USER", raising=False)

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

此行为可以移入 fixture 结构和跨测试共享:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# test_monkeypatching_env.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(EnvironmentError):
_ = get_os_user_lower()

MonkeyPatching 字典

monkeypatch.setitem() 可用于在测试期间安全地将字典值设置为特定值,使用 monkeypatch.delitem() 删除值。

1
2
3
4
5
6
7
# app_dict.py
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']};"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# test_monkeypatchDict_fixture.py
import pytest
import app_dict

def test_connection(monkeypatch):

# Patch the values of DEFAULT_CONFIG to specific
# testing values only for this test.
monkeypatch.setitem(app_dict.DEFAULT_CONFIG, "user", "test_user")
monkeypatch.setitem(app_dict.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_dict.create_connection_string()
assert result == expected

def test_missing_user(monkeypatch):

# patch the DEFAULT_CONFIG t be missing the 'user' key
monkeypatch.delitem(app_dict.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_dict.create_connection_string()