Иногда тесты должны вызывать функции, которые зависят от глобальных настроек или вызывают код, который сложно протестировать, например, доступ к сети. Фикстура 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
?Изменение поведения функции или свойства класса для теста. Например, есть вызов API или подключение к базе данных, которое не будет выполняться для теста, при этом есть ожидаемый результат этого вызова/подключения. Чтобы установить для функции или свойства желаемое поведение при тестировании нужно использовать вызов monkeypatch.setattr()
. Этот вызов может включать пользовательские функции. Чтобы удалить функцию или свойство для теста можно при помощи вызова monkeypatch.delattr()
.
Изменение значений словарей. Например, есть глобальная конфигурация, которую необходимо изменить для определенных тестовых случаев. Чтобы изменить глобальный словарь конфигурации для теста, нужно использовать вызов monkeypatch.setitem()
. для удаления элементов можно использовать вызов monkeypatch.delitem()
.
Изменение переменных среды для теста. Например, для проверки поведения программы при отсутствии переменной среды или для установки нескольких значений известной переменной. Для этих исправлений можно использовать вызовы monkeypatch.setenv()
и monkeypatch.delenv()
.
Для изменения $PATH
также можно использовать вызов monkeypatch.setenv('PATH', value, prepend=os.pathsep)
.
Для изменения контекста текущего рабочего каталога во время теста можно использовать вызов monkeypatch.chdir()
.
Для изменения sys.path
нужно использовать вызов monkeypatch.syspath_prepend()
, который также вызовет pkg_resources.fixup_namespace_packages
и importlib.invalidate_caches()
.
monkeypatch
;monkeypatch
;monkeypatch
;monkeypatch
.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()