目录
函数打补丁(Monkeypatching functions)
有时测试需要调用依赖于全局设置的功能,或者调用那些不易测试的代码,如网络访问。monkeypatch
夹具(fixture)可以帮助你安全地设置/删除属性、字典项或环境变量,或者修改 sys.path
以便进行导入。
monkeypatch
是一种在测试期间临时修改或覆盖代码行为的技术,常用于单元测试中以隔离测试环境或模拟外部依赖。使用 monkeypatch
,你可以:
-
设置/删除属性:你可以修改对象的属性,以便在测试期间改变其行为。这对于测试依赖于某些对象状态的代码特别有用。
-
修改字典项:如果你需要修改配置字典或任何其他类型的字典,
monkeypatch
可以帮助你安全地进行这些修改,而不会影响到原始数据。 -
设置/删除环境变量:环境变量在应用程序中经常被用来控制行为或配置。使用
monkeypatch
,你可以在测试期间设置或删除这些环境变量,以模拟不同的运行环境。 -
修改
sys.path
:有时你可能需要导入不在标准库路径中的模块。通过修改sys.path
,monkeypatch
可以让你在测试期间临时添加新的导入路径。
monkeypatch
的使用方式通常是在测试函数中将其作为参数传递,并在测试期间调用其提供的方法来修改目标对象或环境。这种方式的好处是,一旦测试完成,所有的修改都会被自动撤销,从而保持测试环境的清洁和一致性。
monkeypatch
夹具为测试中的安全打补丁和模拟功能提供了以下辅助方法:
-
monkeypatch.setattr(obj, name, value, raising=True),设置对象
obj
的属性name
为value
。如果raising
为True
(默认值),则在属性不存在时抛出AttributeError
。 -
monkeypatch.delattr(obj, name, raising=True),删除对象
obj
的属性name
。如果raising
为True
(默认值),则在属性不存在时抛出AttributeError
-
monkeypatch.setitem(mapping, name, value),在映射(如字典)
mapping
中设置项name
的值为value
。如果name
已存在,则覆盖其值;如果不存在,则添加新项。此方法不会抛出异常,即使name
不存在。 -
monkeypatch.delitem(obj, name, raising=True),从映射(如字典)
obj
中删除项name
。如果raising
为True
(默认值),则在项不存在时抛出KeyError
。 -
monkeypatch.setenv(name, value, prepend=None),设置环境变量
name
的值为value
。如果prepend
参数被提供且不为None
,则value
将被添加到现有环境变量值的前面,用指定的分隔符(默认为系统路径分隔符)分隔。 -
monkeypatch.delenv(name, raising=True),删除环境变量
name
。如果raising
为True
(默认值),则在环境变量不存在时抛出KeyError
。 -
monkeypatch.syspath_prepend(path),将
path
添加到sys.path
的开头,以便在导入模块时能够先搜索此路径。 -
monkeypatch.chdir(path),更改当前工作目录到
path
。这对于测试依赖于文件系统路径的代码特别有用。 -
monkeypatch.context(),此方法通常用于
with
语句中,以创建一个上下文管理器,该管理器在with
块内部应用一系列修改,并在块结束时自动撤销这些修改。这提供了一种更简洁的方式来临时修改环境或对象状态,并确保这些修改在with
块结束时得到清理。
所有通过这些方法所做的修改都会在请求的测试函数或夹具完成后被撤销。raising
参数决定了如果设置/删除操作的目标不存在时,是否会抛出 KeyError
或 AttributeError
。
考虑以下场景:
-
修改函数行为或类属性以进行测试:例如,你有一个API调用或数据库连接,在测试中你不会实际进行,但你知道预期的输出应该是什么。使用
monkeypatch.setattr
来用你期望的测试行为替换函数或属性。这可以包括你自己的函数。使用monkeypatch.delattr
来在测试期间移除函数或属性。 -
修改字典的值:例如,你有一个全局配置,想要为某些测试用例修改它。使用
monkeypatch.setitem
来为测试打补丁修改字典。monkeypatch.delitem
可以用来移除项。 -
修改环境变量进行测试:例如,为了测试当环境变量缺失时程序的行为,或者为已知变量设置多个值。
monkeypatch.setenv
和monkeypatch.delenv
可用于这些打补丁的情况。 -
使用
monkeypatch.setenv("PATH", value, prepend=os.pathsep)
来修改$PATH
,并使用monkeypatch.chdir
在测试期间更改当前工作目录的上下文。 -
使用
monkeypatch.syspath_prepend
来修改sys.path
,这将同时调用pkg_resources.fixup_namespace_packages
和importlib.invalidate_caches()
。这对于在测试期间修改模块的导入路径很有用。 -
使用
monkeypatch.context
来仅在特定范围内应用打补丁,这有助于控制复杂夹具或标准库打补丁的拆解。这对于需要在特定代码块中临时更改设置,然后自动恢复原始设置的情况特别有用。
请查看monkeypatch blog post,以获取一些入门材料和关于其动机的讨论。这将帮助你更深入地理解monkeypatch
的用途和优势。
函数打补丁(Monkeypatching functions)
考虑一个场景,你正在处理用户目录。在测试的上下文中,你不希望你的测试依赖于正在运行的用户。这时,可以使用monkeypatch
来修补依赖于用户的函数,使其总是返回特定的值。
在这个例子中,monkeypatch.setattr
被用来修补 Path.home
方法,以便在测试运行时总是使用已知的测试路径 Path("/abc")
。这样做消除了测试对运行用户的任何依赖。重要的是,monkeypatch.setattr
必须在调用将使用被修补函数的函数之前被调用。测试函数完成后,对 Path.home
的修改将被撤销。
from pathlib import Path
def getssh():
"""定义一个简单的函数,返回扩展的homedir 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() 时,在这个测试中,monkeypatch 会用 mockreturn 替换 Path.home
x = getssh()
assert x == Path("/abc/.ssh")
在这个测试示例中,getssh
函数原本会返回当前用户Home目录下的 .ssh
路径。但是,通过 monkeypatch.setattr
,我们修改了 Path.home
方法的行为,使其总是返回一个固定的路径 Path("/abc")
。因此,当 getssh
被调用时,它会使用这个模拟的路径来构建 .ssh
路径,而不是实际用户的Home目录路径。这样,测试就独立于运行它的用户了。
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
,即 requests.get
返回的响应对象。模拟的 r
需要有一个 .json()
方法,该方法返回一个字典。这可以在我们的测试文件中通过定义一个类来表示 r
来完成。
# contents of test_app.py, a simple test for our API retrieval
# 导入requests以便进行monkeypatching
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
将 requests.get
替换为 mock_get
函数。mock_get
函数返回一个 MockResponse
类的实例,该类定义了一个 .json()
方法,用于返回一个已知的测试字典,并且不需要任何外部API连接。这样,我们就可以在隔离的环境中测试 get_json
函数,而不必担心实际的网络请求或响应。
注:
@staticmethod
是 Python 中的一个装饰器(decorator),用于将类中的方法声明为静态方法。静态方法与类中的其他方法不同,它不接受类或实例的隐式第一个参数(通常是 self
或 cls
)。这意味着静态方法可以被类直接调用,也可以被类的实例调用,但在方法内部它不能访问或修改类的属性。
静态方法主要用于:
-
工具函数:当你有一个与类相关的函数,但它不需要访问或修改类的属性或方法时,可以将其定义为静态方法。这样,它就像是一个附加在类命名空间中的普通函数。
-
组织代码:将一组相关的函数组织在类的命名空间中,而不是散列在模块级别,可以提高代码的可读性和可维护性。
-
工厂方法:静态方法经常用作工厂方法,即根据一组参数返回类的实例或类的其他对象。由于工厂方法不需要访问类的实例状态,因此它们是静态方法的理想候选者。
在给出的例子中,MockResponse
类中的 json
方法被声明为静态方法,因为它不需要访问类的任何实例属性或类属性,只是简单地返回了一个预定义的字典。
你可以根据你正在测试的场景的复杂程度来构建 MockResponse
类。例如,它可以包含一个 ok
属性,该属性总是返回 True
,或者根据输入字符串从 json()
模拟方法中返回不同的值。
这个模拟可以通过使用 fixture
在多个测试之间共享:
# test_app.py 的内容,针对我们的API检索的简单测试
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"
在这个例子中,mock_response
是一个 fixture
,它使用 monkeypatch
将 requests.get
方法替换为一个总是返回 MockResponse
实例的函数。然后,在 test_get_json
测试函数中,我们通过参数传递来使用这个 fixture
,而不是直接在测试函数中调用 monkeypatch
。这种方式使得测试代码更加清晰和可重用。如果你需要在多个测试中使用相同的模拟,只需在每个测试函数中将 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):
"""为所有测试删除 requests.sessions.Session.request 方法。"""
monkeypatch.delattr("requests.sessions.Session.request")
这里,我们使用了一个自动使用的fixture(通过autouse=True
指定),它利用monkeypatch
插件来删除requests.sessions.Session
类的request
方法。这样,在任何测试中,当你尝试使用requests
库发送HTTP请求时,由于request
方法已被删除,所以请求将不会执行。这是一种在测试环境中模拟或阻止外部HTTP请求的有效方法。
注意
请注意,不建议修补内置函数(如open
、compile
等),因为这可能会破坏pytest的内部机制。如果确实无法避免,尝试传递--tb=native
、--assert=plain
和--capture=no
选项可能会有所帮助,但并不能保证完全解决问题。
注意
请注意,修补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
如果你在处理环境变量,并且经常需要为了测试目的安全地更改它们的值或从系统中删除它们,monkeypatch
提供了一个机制,可以通过 setenv
和 delenv
方法来实现这一点。以下是我们测试所需的示例代码:
# 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
from code import get_os_user_lower
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) # raising=False 表示如果环境变量不存在时不抛出 KeyError
with pytest.raises(OSError):
_ = get_os_user_lower()
这种行为可以移动到fixture结构中,并在多个测试之间共享:
# contents of our test file e.g. test_code.py
import pytest
# 假设 get_os_user_lower 函数已经在某个地方被定义,并可以从相应的模块中导入
# from code import get_os_user_lower
@pytest.fixture
def mock_env_user(monkeypatch):
"""设置 USER 环境变量为 TestingUser 的fixture。"""
monkeypatch.setenv("USER", "TestingUser")
@pytest.fixture
def mock_env_missing(monkeypatch):
"""删除 USER 环境变量的fixture,如果已存在且raising=False则不抛出异常。"""
monkeypatch.delenv("USER", raising=False)
# 注意测试函数现在通过参数引用了fixture来模拟环境变量
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:mock_env_user
和 mock_env_missing
。mock_env_user
fixture 使用 monkeypatch
将 USER
环境变量设置为 TestingUser
,而 mock_env_missing
fixture 则尝试删除 USER
环境变量(如果它已存在,并且由于 raising=False
参数,如果变量不存在则不会抛出异常)。
然后,我们的测试函数通过参数接收这些fixture,从而在测试运行时自动应用它们模拟的环境变量状态。这样,我们就可以在不修改实际环境变量的情况下,安全地测试不同的环境变量配置。
Monkeypatching 字典
monkeypatch.setitem 可以用于在测试期间安全地将字典的值设置为特定值。以下是一个简化的连接字符串示例,展示了如何使用 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']};"
假设你希望测试 create_connection_string
函数在 config
字典中的 user
或 database
键被设置为不同值时的行为。你可以使用 monkeypatch.setitem
来修改 DEFAULT_CONFIG
字典中的这些值,而无需更改实际的 DEFAULT_CONFIG
。
以下是一个使用 pytest 和 monkeypatch 来测试 create_connection_string
函数的示例:
# 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删除值。
import pytest
# 假设 app.py 中包含连接字符串函数
import app
def test_missing_user(monkeypatch):
# 修补 app.DEFAULT_CONFIG 以删除 'user' 键
monkeypatch.delitem(app.DEFAULT_CONFIG, "user", raising=False)
# 预期会抛出 KeyError,因为没有传递配置,并且现在默认的 DEFAULT_CONFIG 中缺少 'user' 条目
with pytest.raises(KeyError):
_ = app.create_connection_string()
monkeypatch.delitem
被用来从 app.DEFAULT_CONFIG
字典中删除 'user'
键,并且设置了 raising=False
以避免在键不存在时抛出异常。然后,测试函数通过调用 app.create_connection_string()
并断言它抛出 KeyError
来验证 DEFAULT_CONFIG
中缺少 'user'
键时的行为。这是因为 create_connection_string
函数在没有显式传递配置且默认配置中缺少必要的键时会尝试访问它,从而导致 KeyError
。
夹具的模块化特性为您提供了灵活性,允许您为每个潜在的模拟定义单独的夹具,并在需要的测试中引用它们。
import pytest
# 假设 app.py 中包含连接字符串函数
import app
# 将所有模拟移动到单独的夹具中
@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 中删除 user 键"""
monkeypatch.delitem(app.DEFAULT_CONFIG, "user", raising=False)
# 测试仅引用所需的夹具模拟
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 类的文档。