如何 monkeypatch/模擬模組和環境

有時,測試需要呼叫依賴於全域設定或呼叫無法輕易測試的程式碼(例如網路存取)的功能。monkeypatch 固定裝置有助於您安全地設定/刪除屬性、字典項目或環境變數,或修改 sys.path 以進行匯入。

monkeypatch 固定裝置提供這些輔助方法,以便在測試中安全地修補和模擬功能

在請求測試函式或固定裝置完成後,所有修改都會復原。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 來僅在特定範圍內套用修補程式,這有助於控制複雜固定裝置或標準函式庫修補程式的移除。

請參閱 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")

修補傳回物件:建立模擬類別

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,也就是傳回的回應物件,以進行測試。模擬的 r 需要一個 .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_get 函數套用 requests.get 的模擬。 mock_get 函數傳回 MockResponse 類別的執行個體,該執行個體定義了 json() 方法,用於傳回已知的測試字典,且不需要任何外部 API 連線。

您可以使用適合您正在測試的場景的適當複雜度來建立 MockResponse 類別。例如,它可以包含始終傳回 Trueok 屬性,或根據輸入字串從模擬的 json() 方法傳回不同的值。

此模擬可以使用 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"

此外,如果模擬設計為套用至所有測試,則 fixture 可以移至 conftest.py 檔案,並使用 with 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 以取得詳細資訊。

修補環境變數

如果您使用環境變數,您通常需要安全地變更其值或將其從系統中刪除以進行測試。 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()

修補字典

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()

固定裝置的模組化讓您可以靈活地為每個可能的模擬定義個別的固定裝置,並在需要的測試中參照它們。

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