Как использовать модули и окружения 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
.