测试有时依赖全局设置,有时会触发网络访问等不方便直接测试的功能。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。
这些方法适合以下场景:
- 测试函数或类属性时,替换其行为。例如不执行真实 API 请求或数据库连接,而让替代函数返回已知数据;也可以暂时删除函数或属性。
- 临时改变全局配置字典,用
setitem设置键值,或用delitem删除键。 - 设置环境变量,或模拟环境变量缺失。
- 用
monkeypatch.setenv("PATH", value, prepend=os.pathsep)给PATH添加前缀,并用chdir临时改变工作目录。 - 用
syspath_prepend修改sys.path;它还会调用pkg_resources.fixup_namespace_packages和importlib.invalidate_caches()。 - 用
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 参考。












暂无评论内容