如何 monkeypatch/mock 模組和環境¶
有時測試需要調用依賴於全域設置或調用難以測試的程式碼(例如網路存取)的功能。monkeypatch
fixture 幫助您安全地設置/刪除屬性、字典項目或環境變數,或修改 sys.path
以進行導入。
monkeypatch
fixture 提供了這些輔助方法,用於在測試中安全地修補和 mock 功能
所有修改將在請求測試函數或 fixture 完成後撤銷。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)
修改 $PATH
,以及 monkeypatch.chdir
在測試期間變更目前工作目錄的上下文。
5. 使用 monkeypatch.syspath_prepend
修改 sys.path
,這也將調用 pkg_resources.fixup_namespace_packages
和 importlib.invalidate_caches()
。
6. 使用 monkeypatch.context
僅在特定範圍內應用修補,這可以幫助控制複雜 fixtures 或對 stdlib 的修補的 teardown。
請參閱 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 返回的物件:建構 mock 類別¶
monkeypatch.setattr
可以與類別結合使用,以 mock 從函數返回的物件,而不是值。想像一個簡單的函數,它接受 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()
我們需要 mock r
,即返回的響應物件,以進行測試。 r
的 mock 需要一個 .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 應用於 requests.get
以及我們的 mock_get
函數。mock_get
函數返回 MockResponse
類別的實例,該類別具有一個 json()
方法,該方法被定義為返回已知的測試字典,並且不需要任何外部 API 連線。
您可以針對您正在測試的情境,以適當的複雜程度建構 MockResponse
類別。例如,它可以包含一個始終返回 True
的 ok
屬性,或者根據輸入字串從 json()
mock 方法返回不同的值。
此 mock 可以使用 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 被設計為應用於所有測試,則可以將 fixture
移動到 conftest.py
檔案,並使用 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 請求的嘗試都會失敗。
注意
請注意,不建議修補內建函數,例如 open
、compile
等,因為它可能會破壞 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
有關詳細資訊,請參閱 #3290。
Monkeypatching 環境變數¶
如果您正在使用環境變數,您通常需要安全地變更值或從系統中刪除它們以進行測試。monkeypatch
提供了一種機制,可以使用 setenv
和 delenv
方法來執行此操作。我們的範例程式碼要測試
# 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()
fixtures 的模組化使您可以靈活地為每個可能的 mock 定義單獨的 fixtures,並在需要的測試中引用它們。
# 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
類別的文件。