如何 monkeypatch/mock 模組和環境

有時測試需要調用依賴於全域設置或調用難以測試的程式碼(例如網路存取)的功能。monkeypatch fixture 幫助您安全地設置/刪除屬性、字典項目或環境變數,或修改 sys.path 以進行導入。

monkeypatch fixture 提供了這些輔助方法,用於在測試中安全地修補和 mock 功能

所有修改將在請求測試函數或 fixture 完成後撤銷。raising 參數決定如果設置/刪除操作的目標不存在,是否會引發 KeyErrorAttributeError

考慮以下情境

1. 修改函數的行為或類別的屬性以進行測試,例如,您有一個 API 調用或資料庫連線,您不會為了測試而進行,但您知道預期的輸出應該是什麼。使用 monkeypatch.setattr 使用您想要的測試行為來修補函數或屬性。這可以包括您自己的函數。使用 monkeypatch.delattr 為測試移除函數或屬性。

2. 修改字典的值,例如,您有一個全域配置,您想要為某些測試案例修改它。使用 monkeypatch.setitem 為測試修補字典。monkeypatch.delitem 可用於移除項目。

3. 修改測試的環境變數,例如,測試環境變數遺失時的程式行為,或將多個值設置為已知變數。monkeypatch.setenvmonkeypatch.delenv 可用於這些修補。

4. 使用 monkeypatch.setenv("PATH", value, prepend=os.pathsep) 修改 $PATH,以及 monkeypatch.chdir 在測試期間變更目前工作目錄的上下文。

5. 使用 monkeypatch.syspath_prepend 修改 sys.path,這也將調用 pkg_resources.fixup_namespace_packagesimportlib.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 類別。例如,它可以包含一個始終返回 Trueok 屬性,或者根據輸入字串從 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 請求的嘗試都會失敗。

注意

請注意,不建議修補內建函數,例如 opencompile 等,因為它可能會破壞 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 提供了一種機制,可以使用 setenvdelenv 方法來執行此操作。我們的範例程式碼要測試

# 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 類別的文件。