Сообщить об ошибке.

Фикстура monkeypatch модуля pytest в Python

Имитация поведения и функциональности в тестах

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

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

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)

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

Для каких сценариев можно использовать фикстуру monkeypatch?

  1. Изменение поведения функции или свойства класса для теста. Например, есть вызов API или подключение к базе данных, которое не будет выполняться для теста, при этом есть ожидаемый результат этого вызова/подключения. Чтобы установить для функции или свойства желаемое поведение при тестировании нужно использовать вызов monkeypatch.setattr(). Этот вызов может включать пользовательские функции. Чтобы удалить функцию или свойство для теста можно при помощи вызова monkeypatch.delattr().

  2. Изменение значений словарей. Например, есть глобальная конфигурация, которую необходимо изменить для определенных тестовых случаев. Чтобы изменить глобальный словарь конфигурации для теста, нужно использовать вызов monkeypatch.setitem(). для удаления элементов можно использовать вызов monkeypatch.delitem().

  3. Изменение переменных среды для теста. Например, для проверки поведения программы при отсутствии переменной среды или для установки нескольких значений известной переменной. Для этих исправлений можно использовать вызовы monkeypatch.setenv() и monkeypatch.delenv().

    Для изменения $PATH также можно использовать вызов monkeypatch.setenv('PATH', value, prepend=os.pathsep).

  4. Для изменения контекста текущего рабочего каталога во время теста можно использовать вызов monkeypatch.chdir().

  5. Для изменения sys.path нужно использовать вызов monkeypatch.syspath_prepend(), который также вызовет pkg_resources.fixup_namespace_packages и importlib.invalidate_caches().

Содержание:

Простой пример использования фикстуры monkeypatch.

Рассмотрим сценарий, в котором ведется работа с пользовательскими каталогами. В контексте тестирования не нужно, чтобы тест зависел от работающего пользователя. Для изменения поведения функций, зависящих от пользователя, чтобы они всегда возвращали определенное значение можно использовать фикстуру monkeypatch.

В этом примере, вызов monkeypatch.setattr() используется для подмены значения, возвращаемого Path.home, чтобы при выполнении теста всегда использовался известный путь тестирования Path('/abc'). Такое поведение устраняет любую зависимость от реального пользователя. Вызов monkeypatch.setattr() должен выполняться до вызова функции, которая будет использовать исправленную функцию. После завершения тестовой функции модификация Path.home будет отменена.

# test_module.py
from pathlib import Path

def getssh():
    """Возвращает расширенный ssh-путь `homedir`"""
    return Path.home() / ".ssh"


def test_getssh(monkeypatch):
"""Тест изменения поведения getssh()"""
    # Определяем функцию имитации 
    # возврата пути `Path.home`
    def mockreturn():
        return Path("/abc")

    # Замена `Path.home` поведением функции `mockreturn()`.
    monkeypatch.setattr(Path, "home", mockreturn)

    # Вызов `getssh()` будет использовать 
    # `mockreturn()` вместо `Path.home`.
    x = getssh()
    assert x == Path("/abc/.ssh")

Имитация возвращаемых объектов monkeypatch.

Вызов monkeypatch.setattr() можно использовать в сочетании с классами для имитации возвращаемых объектов из функций вместо значений. Например есть функция, которая берет URL-адрес API и возвращает ответ json.

# app.py
import requests

def get_json(url):
    """Takes a URL, and returns the JSON."""
    r = requests.get(url)
    return r.json()

Для целей тестирования необходимо имитировать возвращаемый объект ответа r. Макет объекта r нуждается в методе .json(), который возвращает словарь. Создать такое поведение можно определив класс для представления r в тестовом файле.

# test_app.py
import requests for the purposes of monkeypatching
import requests

# app.py, который включает функцию `get_json()` 
# смотрите предыдущий блок кода
import app

# определим класс, который переопределит запросы фиктивным
# возвращаемым значением. Т.е. это ответ из `requests.get()`.
class MockResponse:
    # метод всегда возвращает определенный словарь для тестов
    @staticmethod
    def json():
        return {"mock_key": "mock_response"}

def test_get_json(monkeypatch):
    # этой функции могут быть переданы любые аргументы, но она всегда 
    # будет возвращать объект `MockResponse` с методом `.json()`.
    def mock_get(*args, **kwargs):
        return MockResponse()

    # Изменим поведение `requests.get()` поведением функции `mock_get()`
    monkeypatch.setattr(requests, "get", mock_get)

    # тестируем ответ `app.get_json` из модуля `app.py`
    result = app.get_json("https://fakeurl")
    assert result["mock_key"] == "mock_response"

Фикстура monkeypatch применяет макет для request.get() с помощью функции mock_get(). Функция mock_get() возвращает экземпляр класса MockResponse, который имеет метод .json() не требующий подключения к внешнему API, при этом возвращает известный тестовый словарь.

Можно создать класс MockResponse с соответствующей степенью сложности для тестируемого сценария. Например, он может включать свойство .ok, которое всегда возвращает True, или возвращать разные значения из фиктивного метода .json() на основе входных строк.

Представленный выше макет можно использовать в тестах как фикстуру:

# test_app.py
import pytest
import requests

# app.py
import app

# пользовательский класс, имитирующий `requests.get()`
class MockResponse:
    @staticmethod
    def json():
        return {"mock_key": "mock_response"}

# часть кода предыдущего теста, которая меняет 
# поведение `requests.get()` переносим в фикстуру
@pytest.fixture
def mock_response(monkeypatch):
    """Фикстура изменяющая поведение `requests.get()`"""
    def mock_get(*args, **kwargs):
        return MockResponse()
    # изменяем поведение `requests.get()`
    monkeypatch.setattr(requests, "get", mock_get)

# теперь тест ЗАПРАШИВАЕТ фикстуру `mock_response()`.
def test_get_json(mock_response):
    """Тестовая функция"""
    result = app.get_json("https://fakeurl")
    assert result["mock_key"] == "mock_response"

Кроме того, если макет необходимо применить ко всем тестам, то фикстуру можно переместить в файл conftest.py и использовать аргумент фикстуры autouse=True.

Предотвращение “запросов” от внешних операций.

Например, можно сделать так:

# conftest.py
import pytest

@pytest.fixture(autouse=True)
def no_requests(monkeypatch):
    """Удалить `requests.sessions.Session.request` на все тесты."""
    monkeypatch.delattr("requests.sessions.Session.request")

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

Примечание. Не рекомендуется исправлять встроенные функции, такие как открытие, компиляция и т. д., так как это может привести к поломке внутренних компонентов 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

Имитация значений переменной среды monkeypatch.

В целях тестирования иногда нужно безопасно изменить значения переменной среды или вообще удалить их из системы. Фикстура monkeypatch предоставляет для этого механизм с использованием методов monkeypatch.setenv() и monkeypatch.delenv().

# code.py
import os

def get_os_user_lower():
    """Простая функция поиска. Возвращает USER 
    в нижнем регистре или вызывает OSError"""
    username = os.getenv("USER")
    if username is None:
        raise OSError("USER не определен")

    return username.lower()

Возможны два пути. Во-первых, переменной среды USER присваивается значение. Во-вторых, переменная среды USER не существует. С помощью фикстуры monkeypatch оба пути можно безопасно протестировать, не влияя на оригинальную рабочую среду:

# test_code.py
import pytest

def test_upper_to_lower(monkeypatch):
    """Проверка правильности поиска и извлечения 
    тестового пользователя из переменной среды"""
    monkeypatch.setenv("USER", "TestingUser")
    assert get_os_user_lower() == "testinguser"

def test_raise_exception(monkeypatch):
    """Проверка возникновения исключения при отсутствии 
    пользователя в переменной среде."""
    monkeypatch.delenv("USER", raising=False)

    with pytest.raises(OSError):
        _ = get_os_user_lower()

Это поведение можно переместить в фикстуры для использования их в тестах:

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

# тест запрашивает фикстуру `mock_env_user()`
def test_upper_to_lower(mock_env_user):
    assert get_os_user_lower() == "testinguser"

# тест запрашивает фикстуру `mock_env_missing()`
def test_raise_exception(mock_env_missing):
    with pytest.raises(OSError):
        _ = get_os_user_lower()

Имитация изменения значений в словаре monkeypatch.

Для безопасной установки определенных значений словарей во время тестов можно использовать вызов monkeypatch.setitem().

# app.py
DEFAULT_CONFIG = {"user": "user1", "database": "db1"}

def create_connection_string(config=None):
    """Создает строку подключения из входных данных или значений по умолчанию"""
    config = config or DEFAULT_CONFIG
    return f"User Id={config['user']}; Location={config['database']};"

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

# test_app.py
import app # подключение app.py с предыдущим блоком кода  

def test_connection(monkeypatch):
    # Имитация значений ключей `DEFAULT_CONFIG`, только для этого теста.
    monkeypatch.setitem(app.DEFAULT_CONFIG, "user", "test_user")
    monkeypatch.setitem(app.DEFAULT_CONFIG, "database", "test_db")

    # ожидаемый результат, основанный на исправлении
    expected = "User Id=test_user; Location=test_db;"

    # в тесте используются измененные настройки словаря
    result = app.create_connection_string()
    assert result == expected

Для имитации удаления значений словаря можно использовать вызов monkeypatch.delitem().

# test_app.py
import pytest
# подключение app.py
import app

def test_missing_user(monkeypatch):
    # в словаре DEFAULT_CONFIG имитируется отсутствие ключа 'user' 
    monkeypatch.delitem(app.DEFAULT_CONFIG, "user", raising=False)

    # Ожидается `KeyError`, т.к. в DEFAULT_CONFIG  
    # теперь отсутствует запись "user".
    with pytest.raises(KeyError):
        _ = app.create_connection_string()

Модульность фикстур дает возможность определять отдельные фикстуры для каждого потенциального макета и ссылаться на них в необходимых тестах.

# test_app.py
import pytest
# импортируем app.py
import app

# all of the mocks are moved into separated fixtures
@pytest.fixture
def mock_test_user(monkeypatch):
    """Имитация тестового пользователя в DEFAULT_CONFIG"""
    monkeypatch.setitem(app.DEFAULT_CONFIG, "user", "test_user")

@pytest.fixture
def mock_test_database(monkeypatch):
    """Имитация тестовой базы в DEFAULT_CONFIG"""
    monkeypatch.setitem(app.DEFAULT_CONFIG, "database", "test_db")


@pytest.fixture
def mock_missing_default_user(monkeypatch):
    """Имитация удаления пользователя из DEFAULT_CONFIG"""
    monkeypatch.delitem(app.DEFAULT_CONFIG, "user", raising=False)

# Обратите внимание, что тесты запрашивают 
#только необходимые фикстуры

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