python 之 pytest mock 数据(七)

pytest之Monkeypatching猴子补丁

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

概念:

在运行时动态修改模块、类或函数,通常是添加功能或修正缺陷。

猴子补丁在代码运行时(内存中)发挥作用,不会修改源码,因此只对当前运行的程序实例有效。

因为猴子补丁破坏了封装,而且容易导致程序与补丁代码的实现细节紧密耦合,所以被视为临时的变通方案,不是集成代码的推荐方式。
在Python语言中,monkey patch 指的是对于一个类或者模块所进行的动态修改。在Python语言中,我们其实可以在运行时修改代码的行为。

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

1
2
3
4
5
6
7
8
9
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 参数确定是否引发KeyError或。AttributeError

请考虑以下情形:

  1. 修改函数的行为或类的属性以进行测试,例如,有一个 API 调用或数据库连接,你不会对其进行测试,但你知道预期的输出应该是什么。使用monkeypatch.setattr修补函数或属性以达到您想要的测试行为。这也可以包括自己定义自己的函数。使用monkeypatch.delattr删除测试的函数或属性。
  2. 修改字典的值例如,你有一个全局配置,想要针对某些测试用例进行修改。用于monkeypatch.setitem修补测试字典。monkeypatch.delitem可用于删除项目。
  3. 修改环境变量以进行测试,例如,如果缺少环境变量,则测试程序行为,或者为已知变量设置多个值。 monkeypatch.setenv并可monkeypatch.delenv用于这些补丁。
  4. 使用修改,并 在测试期间更改当前工作目录的上下文。monkeypatch.setenv("PATH", value, prepend=os.pathsep)``$PATHmonkeypatch.chdir
  5. 使用monkeypatch.syspath_prepend修改sys.path也会调用pkg_resources.fixup_namespace_packagesimportlib.invalidate_caches()
  6. monkeypatch.context仅在特定范围内应用补丁,这有助于控制复杂装置或 stdlib 补丁的拆除。

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
from pathlib import Path

print(Path.home())
def getssh():
"""Simple function to return expanded homedir ssh path."""
return Path.home() / ".ssh" # Path.home()返回的是/home/user ,拼接后/home/user/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) # 设置,当你调用Path.home时,使用mockreturn ,mockreturn返回的是/abc

# Calling getssh() will use mockreturn in place of Path.home
# for this test with the monkeypatch.
x = getssh() # 调用getssh(),实际调用的是mockreturn,返回的是/abc
print(x)
assert x == Path("/abc/.ssh")

Monkeypatching 返回对象:构建模拟类

monkeypatch.setattr也可以与类结合使用,模拟函数所返回的是对象而不是值。定义一个简单的函数,它接受 API url 并返回 json 响应。

1
2
3
4
5
6
7
# 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) # 发起url请求
return r.json()

为了测试,我们需要模拟r返回的响应对象。模拟的r对象必须要有一个.json()返回字典的方法。接下来我们可以通过在测试文件中定义一个类来 mock r对象。

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
28
29
30
31
# 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 # 导入app.py


# 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"} # MockResponse的.json()方法总是返回一个字典


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() # 返回MockResponse对象,这里返回的就是我们要mock的r对象

# apply the monkeypatch for requests.get to mock_get
monkeypatch.setattr(requests, "get", mock_get) # 给requests.get方法的返回值定义为我们的mock_get所返回的MockResponse对象
# 这里就相当于什么,相当于之前的 r = requests.get("https://fakeurl") 就等于我们的r = MockResponse()所返回的MockResponse对象

# app.get_json, which contains requests.get, uses the monkeypatch
result = app.get_json("https://fakeurl") # 这里就相当于就是 r.json()也相当于是我们mock的对象的mock_get()所返回的{"mock_key": "mock_response"})
assert result["mock_key"] == "mock_response"

测试成功,打印一下返回内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
============================= test session starts ==============================
platform linux -- Python 3.10.12, pytest-8.1.1, pluggy-1.4.0 -- /home/wang/code/fastApiStudy/venvadmin/bin/python
cachedir: .pytest_cache
rootdir: /home/wang/code/
configfile: pytest.ini
plugins: anyio-4.2.0
collecting ... collected 1 item

testcases/test_app.py::test_get_json
result: {'mock_key': 'mock_response'} # 打印出来就是我们设置的
PASSED

============================== 1 passed in 0.03s ===============================
Finished running tests!

也可以根据测试场景构建一些复杂的类给到mockresponse

例如这个类返回的属性中始终会有一个ok的属性,

或者根据输入的不同的字符串给.json()方法返回不同的值

也可以使用以下方式在测试之间共享此模拟fixture

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
28
29
30
 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想要应用于所有的测试用例,则fixture可以在conftest.py文件中定义 ,并在pytest.ini配置文件中使用withautouse=True这个选项选项。

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

如果你想阻止“requests”库在所有测试中执行http请求,你可以执行以下操作

1
2
3
4
5
6
7
8
# 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")

此自动使用装置将针对每个测试功能执行,它将删除该方法request.session.Session.request ,以便在测试中创建 http 请求的任何尝试都将失败。

Monkeypatching 环境变量

如果您正在使用环境变量,则经常需要安全地更改值或将其从系统中删除以进行测试。提供了一种使用and方法monkeypatch执行此操作的机制。我们的示例代码用于测试:setenv``delenv

1
2
3
4
5
6
7
8
9
10
11
12
13
# 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 两种路径都可以安全地进行测试,而不会影响运行环境:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 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结构中并在测试之间共享:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 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可用于在测试期间安全地将字典的值设置为特定值。以下是简化的连接字符串示例:

1
2
3
4
5
6
7
8
# 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字典修补为特定值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 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来删除值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 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()

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

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
28
29
30
31
32
33
34
35
36
37
# 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()

python 之 pytest mock 数据(七)
http://example.com/2024/01/20/667python-之-pytest-mock-数据(七)/
作者
Wangxiaowang
发布于
2024年1月20日
许可协议