pytest 核心功能-monkeypatch/mock 模塊和環(huán)境

2022-03-21 10:21 更新

有時(shí)測(cè)試需要調(diào)用依賴于全局設(shè)置的功能或調(diào)用不容易測(cè)試的代碼,例如網(wǎng)絡(luò)訪問(wèn)。 ?monkeypatch fixture?可幫助您安全地設(shè)置/刪除屬性、字典項(xiàng)或環(huán)境變量,或修改 ?sys.path? 以進(jìn)行導(dǎo)入。

?monkeypatch fixture?提供了以下幫助方法,用于在測(cè)試中安全地打補(bǔ)丁和模擬功能:

monkeypatch.setattr(obj, name, value, raising=True)
monkeypatch.setattr("somemodule.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=None)
monkeypatch.delenv(name, raising=True)
monkeypatch.syspath_prepend(path)
monkeypatch.chdir(path)

在請(qǐng)求的測(cè)試功能或?fixture?完成后,所有修改都將被撤消。raise參數(shù)確定如果設(shè)置/刪除操作的目標(biāo)不存在,是否會(huì)引發(fā) ?KeyError或 ?AttributeError?

考慮以下場(chǎng)景:

  1. 修改函數(shù)的行為或類的屬性,例如,有一個(gè)API調(diào)用或數(shù)據(jù)庫(kù)連接,你不會(huì)進(jìn)行測(cè)試,但你知道預(yù)期的輸出應(yīng)該是什么。使用monkeypatch。將函數(shù)或?qū)傩耘c您想要的測(cè)試行為進(jìn)行修補(bǔ)。這可以包括您自己的函數(shù)。使用monkeypatch.delattr刪除測(cè)試的函數(shù)或?qū)傩浴?/li>
  2. 修改字典的值,例如 您有一個(gè)要針對(duì)某些測(cè)試用例修改的全局配置。 使用 monkeypatch.setitem 修補(bǔ)字典以進(jìn)行測(cè)試。 monkeypatch.delitem 可用于刪除項(xiàng)目。
  3. 修改測(cè)試的環(huán)境變量,例如 如果缺少環(huán)境變量,則測(cè)試程序行為,或?qū)⒍鄠€(gè)值設(shè)置為已知變量。 monkeypatch.setenv 和 monkeypatch.delenv 可用于這些補(bǔ)丁。
  4. 使用 ?monkeypatch.setenv("PATH", value, prepend=os.pathsep)? 修改 ?$PATH?,并使用 ?monkeypatch.chdir? 在測(cè)試期間更改當(dāng)前工作目錄的上下文。
  5. 使用 ?monkeypatch.syspath_prepend? 修改 ?sys.path?,它還將調(diào)用 ?pkg_resources.fixup_namespace_packages? 和 ?importlib.invalidate_caches()?

簡(jiǎn)單示例:monkeypatching 函數(shù)

考慮一個(gè)使用用戶目錄的場(chǎng)景。 在測(cè)試的上下文中,您不希望您的測(cè)試依賴于正在運(yùn)行的用戶。 ?monkeypatch ?可用于修補(bǔ)依賴于用戶的函數(shù)以始終返回特定值。

在此示例中,?monkeypatch.setattr? 用于修補(bǔ) ?Path.home?,以便在運(yùn)行測(cè)試時(shí)始終使用已知的測(cè)試路徑 ?Path("/abc")?。 這消除了出于測(cè)試目的對(duì)運(yùn)行用戶的任何依賴。 必須在調(diào)用將使用修補(bǔ)函數(shù)的函數(shù)之前調(diào)用 ?monkeypatch.setattr?。 測(cè)試功能完成后 ?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 返回的對(duì)象:building mock classes

?monkeypatch.setattr?可以與類結(jié)合使用,以模擬從函數(shù)返回的對(duì)象而不是值。 想象一個(gè)簡(jiǎn)單的函數(shù)來(lái)獲取 API url 并返回 json 響應(yīng)。

# 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?,返回的響應(yīng)對(duì)象用于測(cè)試目的。 ??的?mock?需要一個(gè)返回字典的 ?.json()? 方法。 這可以在我們的測(cè)試文件中通過(guò)定義一個(gè)代表 ?r? 的類來(lái)完成。

# 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 ?函數(shù)對(duì) ?requests.get? 應(yīng)用模擬。 ?mock_get函數(shù)返回一個(gè) ?MockResponse類的實(shí)例,它定義了一個(gè) ?json()? 方法來(lái)返回一個(gè)已知的測(cè)試字典,并且不需要任何外部 API 連接。

您可以為您正在測(cè)試的場(chǎng)景構(gòu)建具有適當(dāng)復(fù)雜程度的 ?MockResponse類。 例如,它可以包含一個(gè)始終返回 ?True的 ?ok屬性,或者根據(jù)輸入字符串從 ?json()? 模擬方法返回不同的值。

這個(gè)模擬可以使用?fixture?在測(cè)試之間共享:

# 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?被設(shè)計(jì)為應(yīng)用于所有測(cè)試,則可以將?fixture?移動(dòng)到?conftest.py?文件并使用autuse =True?選項(xiàng)。

全局補(bǔ)丁示例:防止遠(yuǎn)程操作的請(qǐng)求

如果你想阻止?requests?庫(kù)在所有測(cè)試中執(zhí)行http請(qǐng)求,你可以這樣做:

# 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")

將為每個(gè)測(cè)試函數(shù)執(zhí)行此 ?autouse fixture?,并將刪除方法 ?request.session.Session.request? 以便測(cè)試中創(chuàng)建 http 請(qǐng)求的任何嘗試都將失敗。

請(qǐng)注意,不建議修補(bǔ)內(nèi)置函數(shù),例如 ?open?、?compile等,因?yàn)樗赡軙?huì)破壞 pytest 的內(nèi)部結(jié)構(gòu)。 如果這是不可避免的,傳遞 ?--tb=native?、?--assert=plain? 和 ?--capture=no? 可能會(huì)有所幫助,盡管不能保證。

請(qǐng)注意,pytest使用的?stdlib?函數(shù)和一些第三方庫(kù)補(bǔ)丁可能會(huì)破壞pytest本身,因此在這些情況下,建議使用?MonkeyPatch.context()?來(lái)限制補(bǔ)丁到你想要測(cè)試的塊:

import functools


def test_partial(monkeypatch):
    with monkeypatch.context() as m:
        m.setattr(functools, "partial", 3)
        assert functools.partial == 3

Monkeypatching環(huán)境變量

如果您正在使用環(huán)境變量,那么為了測(cè)試的目的,您經(jīng)常需要安全地更改這些值或從系統(tǒng)中刪除它們。?Monkeypatch?提供了一種使用?setenv?和?delenv?方法來(lái)實(shí)現(xiàn)這一點(diǎn)的機(jī)制。例如:

# 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 ?環(huán)境變量設(shè)置為一個(gè)值。 其次,?USER?環(huán)境變量不存在。 使用 ?monkeypatch ?可以安全地測(cè)試兩個(gè)路徑,而不會(huì)影響運(yùn)行環(huán)境:

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

這種行為可以轉(zhuǎn)移到?fixture?結(jié)構(gòu)中,并在測(cè)試中共享:

# 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? 可用于在測(cè)試期間將字典的值安全地設(shè)置為特定值。 以這個(gè)簡(jiǎn)化的連接字符串為例:

# 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']};"

出于測(cè)試目的,我們可以將 ?DEFAULT_CONFIG字典修補(bǔ)為特定值。

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

?fixture?的模塊化使您可以靈活地為每個(gè)潛在的?mock?定義單獨(dú)的?fixture?,并在所需的測(cè)試中引用它們。

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


以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)