Spec-Zone.ru › pytest

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

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

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

  • monkeypatch.setattr(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)
  • monkeypatch.context()

Все изменения будут отменены после завершения тестовой функции или фикстуры, запросившей их. Параметр 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, чтобы применять подмены только в определённой области действия. Это может помочь контролировать завершение сложных фикстур или подмен стандартной библиотеки.

В публикации в блоге о подмене объектов можно найти вводные материалы и обсуждение причин использования этого подхода.

Подмена функций

Рассмотрим сценарий работы с каталогами пользователей. При тестировании не нужно, чтобы тест зависел от пользователя, запустившего его. 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 можно использовать вместе с классами, чтобы имитировать возвращаемые функциями объекты, а не значения. Представьте простую функцию, которая принимает URL API и возвращает 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» к удалённым ресурсам

Если вы хотите запретить библиотеке «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")

Эта автоматически используемая фикстура выполняется для каждой тестовой функции и удаляет метод request.session.Session.request, поэтому любые попытки отправить HTTP-запросы внутри тестов завершатся ошибкой.

Примечание

Обратите внимание: не рекомендуется подменять встроенные функции, такие как open, compile и т. д., поскольку это может нарушить внутреннюю работу pytest. Если этого не избежать, могут помочь параметры --tb=native, --assert=plain и --capture=no, хотя никаких гарантий нет.

Примечание

Имейте в виду, что подмена функций stdlib и некоторых сторонних библиотек, используемых pytest, может нарушить работу самого pytest. Вместо подмены исходного объекта в стандартной библиотеке предпочтительнее подменять ссылку, используемую вашим кодом. Например, если ваш модуль выполняет from os import getcwd, подменяйте mymodule.getcwd, а не os.getcwd.

Для контролируемого вами кода более безопасный подход в долгосрочной перспективе — явно указывать зависимости, чтобы их можно было передавать в тестируемый код, а не подменять глобально. Если подмены объекта стандартной библиотеки не избежать, используйте MonkeyPatch.context(), чтобы ограничить подмену тестируемым блоком:

import functools


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

Подробнее см. #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 to 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.

© 2015–2026 Holger Krekel and pytest-dev team
Licensed under the MIT License.
https://docs.pytest.org/en/stable/how-to/monkeypatch.html

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API