Как использовать модули и окружения monkeypatch/mock

Иногда тесты должны вызывать функциональность, которая зависит от глобальных настроек или вызывает код, который невозможно легко протестировать, например, доступ к сети. Приспособление monkeypatch поможет вам безопасно установить/удалить атрибут, элемент словаря или переменную окружения, или изменить sys.path для импорта.

Фикстура monkeypatch предоставляет эти вспомогательные методы для безопасного исправления и мокинга функциональности в тестах:

Все изменения будут отменены после завершения работы запрашивающей тестовой функции или приспособления. Параметр raising определяет, будет ли выдан сигнал KeyError или AttributeError, если цель операции установки/удаления не существует.

Рассмотрим следующие сценарии:

1. Modifying the behavior of a function or the property of a class for a test e.g. there is an API call or database connection you will not make for a test but you know what the expected output should be. Use monkeypatch.setattr to patch the function or property with your desired testing behavior. This can include your own functions. Use monkeypatch.delattr to remove the function or property for the test.

2. Modifying the values of dictionaries e.g. you have a global configuration that you want to modify for certain test cases. Use monkeypatch.setitem to patch the dictionary for the test. monkeypatch.delitem can be used to remove items.

3. Modifying environment variables for a test e.g. to test program behavior if an environment variable is missing, or to set multiple values to a known variable. monkeypatch.setenv and monkeypatch.delenv can be used for these patches.

4. Use monkeypatch.setenv("PATH", value, prepend=os.pathsep) to modify $PATH, and monkeypatch.chdir to change the context of the current working directory during a test.

5. Use monkeypatch.syspath_prepend to modify sys.path which will also call pkg_resources.fixup_namespace_packages and importlib.invalidate_caches().

6. Use monkeypatch.context to apply patches only in a specific scope, which can help control teardown of complex fixtures or patches to the stdlib.

Некоторый вводный материал и обсуждение его мотивации см. в monkeypatch blog post.

Функции сопряжения с обезьянами

Рассмотрим сценарий, в котором вы работаете с каталогами пользователей. В контексте тестирования вы не хотите, чтобы ваш тест зависел от запущенного пользователя. 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")

Сопряжение с обезьянами возвращенных объектов: построение mock-классов

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 применяет насмешку для requests.get с помощью нашей функции mock_get. Функция mock_get возвращает экземпляр класса MockResponse, который имеет метод json(), определенный для возврата известного словаря тестирования и не требующий внешнего подключения к API.

Вы можете создать класс MockResponse с соответствующей степенью сложности для сценария, который вы тестируете. Например, он может включать свойство ok, которое всегда возвращает True, или возвращать различные значения из подражаемого метода 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» выполнять 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 будет выполняться для каждой тестовой функции, и оно удалит метод 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

Подробнее см. в разделе issue #3290.

Переменные окружения для обезьян

Если вы работаете с переменными окружения, вам часто требуется безопасно изменить значения или удалить их из системы в целях тестирования. 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()

Словари для обезьян

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.

Вернуться на верх