monkeypatch: подмена функций и окружения в тестах
Функция ходит во внешний мир — за курсом валют, случайностью, переменными окружения — и тест теряет управление. monkeypatch подменяет внешний мир заглушкой на время теста и убирает её сам.
Редакция Питоники
Хороший тест — управляемый: ты точно знаешь вход и ожидаешь точный выход. Но многие функции ходят за данными во внешний мир: конвертер валют лезет за курсом, скрипт публикации читает переменные окружения, генератор отчёта зовёт random. В тесте этот мир неуправляем: курс меняется каждый час, окружение зависит от машины, случайность — от фазы луны. Тест, зависящий от внешнего мира, падает без причины — а падения без причины перестают значить что-либо.
Решение — подменить внешний мир на заглушку: вместо похода в интернет тест подсовывает функцию, возвращающую фиксированный курс. Умение pytest здесь в том, чтобы подмена жила ровно один тест и стиралась сама — за это отвечает встроенная фикстура monkeypatch. Разберём её три главных метода: setattr, setitem и setenv.
Слово «заглушка» здесь точнее, чем «мок»: мы не проверяем, как вызвали функцию (хотя и это научимся), а обеспечиваем предсказуемый ответ. Подменённый мир — часть подготовки теста, такая же, как данные из фикстур: разместил заглушку, вызвал функцию, сверил результат. Никакой новой философии — только контроль над тем, что раньше было случайностью.
setattr: заглушка вместо функции
Модуль shop конвертирует евро в рубли по курсу, который достаёт функция get_rate — в настоящем проекте это запрос к API или чтение конфига. Тест не может позволить себе настоящий API, поэтому подменяет get_rate заглушкой:
from pathlib import Path
Path("shop.py").write_text('''
def get_rate():
# в настоящем проекте здесь запрос к курсу валют или чтение конфига
return 100.0
def to_rub(price_eur):
return price_eur * get_rate()
''', encoding="utf-8")
Path("test_shop.py").write_text('''
import shop
def test_to_rub_with_stub(monkeypatch):
monkeypatch.setattr(shop, "get_rate", lambda: 90.0)
assert shop.to_rub(2) == 180.0
def test_original_is_back():
assert shop.to_rub(2) == 200.0
''', encoding="utf-8")
import pytest
rc = pytest.main(["test_shop.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
.. [100%] 2 passed in 0.01s exit: 0
Читаем setattr по аргументам: подмени в объекте shop атрибут get_rate на функцию lambda: 90.0. Дальше тест зовёт to_rub — а та, в свою очередь, зовёт уже заглушку и получает предсказуемые 90.0. Второй тест — самый важный: он запускается уже после подмены и проверяет, что to_rub снова считает по оригинальному курсу 100.0. Оба теста зелёные — значит, monkeypatch снял заглушку после первого теста автоматически.
Вот в чём главная услуга фикстуры. Подмена живёт ровно один тест и стирается сама, независимо от того, прошёл тест или упал. Проверка восстановления встроена прямо в пример — второй тест не даёт подмене остаться незамеченной. Такая страховка стоит двух строк и экономит часы охоты за «плавающими» сбоями.
setenv и delenv: переменные окружения
Второй частый гость в функциях — os.environ: ключи API, адреса сервисов, режимы работы. Настоящая машина тестам не подходит: на ней ключ может быть, а может и не быть. monkeypatch.setenv ставит переменную на время теста, delenv убирает:
from pathlib import Path
Path("settings.py").write_text('''
import os
def api_url():
host = os.environ.get("API_HOST", "localhost")
return "https://" + host + "/v1"
''', encoding="utf-8")
Path("test_settings.py").write_text('''
import settings
def test_host_from_env(monkeypatch):
monkeypatch.setenv("API_HOST", "api.example.ru")
assert settings.api_url() == "https://api.example.ru/v1"
def test_default_host(monkeypatch):
monkeypatch.delenv("API_HOST", raising=False)
assert settings.api_url() == "https://localhost/v1"
''', encoding="utf-8")
import pytest
rc = pytest.main(["test_settings.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
.. [100%] 2 passed in 0.01s exit: 0
Первый тест проверяет ветку «переменная задана»: setenv ставит API_HOST, и функция строит адрес из него. Второй — ветку «переменной нет»: delenv убирает её, причём с аргументом raising=False, чтобы отсутствие не считалось ошибкой. После каждого теста окружение возвращается в исходное состояние — хоть переменные задавались на настоящей машине, хоть нет.
Подменяем даже случайность
setattr подменяет не только свои функции, но и чужие — например, из стандартной библиотеки. Функция lucky_number тянет число из random.choice: запусти тест без подмены — и каждый прогон честный, но непредсказуемый. Заглушка делает его детерминированным:
from pathlib import Path
Path("lottery.py").write_text('''
import random
def lucky_number():
return random.choice([1, 2, 3, 4, 5])
''', encoding="utf-8")
Path("test_lottery.py").write_text('''
import lottery
import random
def test_lucky_is_seven(monkeypatch):
monkeypatch.setattr(random, "choice", lambda seq: 7)
assert lottery.lucky_number() == 7
def test_random_is_back():
assert lottery.lucky_number() in [1, 2, 3, 4, 5]
''', encoding="utf-8")
import pytest
rc = pytest.main(["test_lottery.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
.. [100%] 2 passed in 0.01s exit: 0
Подмена сел не в lottery, а прямо в модуль random — setattr это позволяет: первый аргумент может быть любым объектом с атрибутами. Тест стал строгим: lucky_number обязана вернуть то, что отдал random.choice, — ровно 7. Второй тест убеждается, что случайность вернулась на место: заглушка не пережила свой тест.
def test_lucky_manual():
original = random.choice
random.choice = lambda seq: 7
try:
assert lottery.lucky_number() == 7
finally:
random.choice = original
chdir: рабочая папка на время теста
Функции, читающие файлы по относительным путям, зависят от текущей папки процесса — ещё одна непредсказуемая часть внешнего мира. monkeypatch.chdir переезжает в указанный каталог и после теста возвращается обратно. В паре с tmp_path получается полностью изолированный мир для файловых тестов:
from pathlib import Path
Path("test_cwd.py").write_text('''
def test_reads_from_cwd(monkeypatch, tmp_path):
(tmp_path / "config.txt").write_text("host=local", encoding="utf-8")
monkeypatch.chdir(tmp_path)
assert open("config.txt", encoding="utf-8").read() == "host=local"
''', encoding="utf-8")
import pytest
rc = pytest.main(["test_cwd.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
. [100%] 1 passed in 0.02s exit: 0
Относительный путь config.txt после chdir указывает во временную папку из tmp_path: тест прочитал свой файл и не тронул ничего вокруг. Такая связка выручает для кода, которому путь передать нельзя — он сам собирает относительные пути внутри.
Заглушка-шпион: не только ответ, но и вызовы
До сих пор заглушки только возвращали значения. Но частый вопрос к тесту другой: функция вообще вызвала отправку? С каким текстом? Ответ — заглушка, которая не возвращает значение, а запоминает вызов в список. Такой приём называют шпионом:
from pathlib import Path
Path("alerts.py").write_text('''
def send(text):
# в настоящем проекте - поход в сеть или очередь сообщений
raise RuntimeError("no network in tests")
def low_balance(name, balance):
if balance < 100:
send(name + ": низкий баланс " + str(balance))
''', encoding="utf-8")
Path("test_alerts.py").write_text('''
import alerts
def test_low_balance_sends_alert(monkeypatch):
sent = []
monkeypatch.setattr(alerts, "send", lambda text: sent.append(text))
alerts.low_balance("иван", 50)
assert sent == ["иван: низкий баланс 50"]
def test_rich_client_no_alert(monkeypatch):
sent = []
monkeypatch.setattr(alerts, "send", lambda text: sent.append(text))
alerts.low_balance("олег", 500)
assert sent == []
''', encoding="utf-8")
import pytest
rc = pytest.main(["test_alerts.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
.. [100%] 2 passed in 0.01s exit: 0
Заметь деталь: настоящий send бросает RuntimeError — сети в тестах нет, и без подмены оба теста упали бы. Заглушка решает две задачи разом: изолирует тест от сети и записывает, с каким текстом её позвали. Первый тест проверяет бедного клиента — уведомление ушло с правильным текстом; второй — богатого: список пуст, лишних писем нет. Одна заглушка, два поведения, ноль походов в сеть.
У setattr есть и строковая форма: monkeypatch.setattr("alerts.send", заглушка) — путь «модуль.атрибут» одной строкой. Она короче, но прячет зависимость от строковых имён: опечатка всплывёт только при запуске. Форма с объектом предпочтительнее — подсказки редактора работают, а переименование в модуле сломает тест на этапе импорта.
def test_rate_string_form(monkeypatch):
monkeypatch.setattr("shop.get_rate", lambda: 90.0)
assert shop.to_rub(2) == 180.0
setitem: подмена в словаре-конфиге
Конфигурация в проектах часто живёт в словаре на уровне модуля: тарифы, лимиты, флаги функций. Тарифы доставки — классика: тест хочет проверить расчёт по тарифу, которого в продакшене нет. monkeypatch.setitem подменяет значение ключа и восстанавливает его после теста:
from pathlib import Path
Path("tariffs.py").write_text('''
TARIFFS = {"standard": 100, "express": 250}
def delivery_cost(tariff):
return TARIFFS.get(tariff, 0)
''', encoding="utf-8")
Path("test_tariffs.py").write_text('''
import tariffs
def test_express_overridden(monkeypatch):
monkeypatch.setitem(tariffs.TARIFFS, "express", 300)
assert tariffs.delivery_cost("express") == 300
def test_dict_restored():
assert tariffs.TARIFFS["express"] == 250
''', encoding="utf-8")
import pytest
rc = pytest.main(["test_tariffs.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
.. [100%] 2 passed in 0.01s exit: 0
setitem(словарь, ключ, значение) — и на время теста express стоит 300, хотя в модуле прописано 250. Второй тест снова играет роль сторожа: словарь вернулся к оригиналу. Почему нельзя просто tariffs.TARIFFS["express"] = 300? По той же причине, что и с функциями: правка навсегда — последующие тесты увидят изменённый тариф и, если проверяют 250, упадут в зависимости от порядка.
| Метод | Что подменяет | Пример |
|---|---|---|
| setattr | атрибут модуля или класса | setattr(shop, "get_rate", заглушка) |
| setitem | значение в словаре | setitem(TARIFFS, "express", 300) |
| setenv | переменную окружения | setenv("API_KEY", "test") |
| delenv | убирает переменную окружения | delenv("API_KEY", raising=False) |
| chdir | рабочую папку процесса | chdir(tmp_path) |
Что подменять: внешние границы модуля
Подмена — инструмент, которым легко злоупотребить. Если заглушить каждую функцию тестируемого модуля, тест будет проверять заглушки, а не код: он пройдёт, даже когда вся логика сломана. Рабочее правило — подменять внешние границы: сеть, файловую систему за пределами временной папки, случайность, время, окружение. Всё, что внутри модуля, — оставляй настоящим: именно его поведение и проверяет тест. Запах перебора — тест с тремя и более заглушками: это сигнал пересобрать архитектуру, а не наращивать подмены.
Что дальше
Ты изолировал внешний мир: setattr ставит заглушку вместо функции, setitem правит словарь-конфиг, setenv задаёт окружение — и всё возвращается на место после теста само. Это третья встроенная фикстура в копилке после capsys и tmp_path. Дальше — метки skip, xfail и собственные mark: как пометить тест «не запускать» или «ожидаемо падает» и отобрать только нужные. А заглушки из этого урока станут одним из инструментов финального проекта — тест-сьюта для целого модуля.
Тест без внешнего мира не падает от новостей: курс валют, окружение и случайность подменяются заглушкой и стираются после теста.
Сначала предскажи ответ в голове — это главный навык программиста.
state = {"rate": 100}
def to_rub(eur):
return eur * state["rate"]
saved = state["rate"]
state["rate"] = 90
first = to_rub(2)
state["rate"] = saved
second = to_rub(2)
print(first, second)
import os
os.environ["TOKEN"] = "real"
token = os.environ.pop("TOKEN", None)
print("TOKEN" in os.environ, os.environ.get("TOKEN", "нет"))
1. Что делает monkeypatch.setattr(shop, "get_rate", lambda: 90.0)?
2. Что происходит с подменами monkeypatch после завершения теста?
3. Чем опасно прямое присваивание shop.get_rate = lambda: 90.0 в тесте?
4. Какой метод monkeypatch ставит переменную окружения?
5. Что делает monkeypatch.setitem(tariffs.TARIFFS, "express", 300)?
6. Какие функции стоит подменять monkeypatch-ом?
Модуль auth содержит функцию read_api_key(), которая читает переменную окружения API_KEY. Напиши тест с monkeypatch.setenv: подставь ключ test-key-123 и проверь, что read_api_key() возвращает именно его.
Что такое monkeypatch в pytest?
Встроенная фикстура для подмен на время теста: monkeypatch.setattr заменяет атрибут модуля или класса, setitem — значение в словаре, setenv — переменную окружения. Главное свойство: после завершения теста — успешно или с падением — все подмены откатываются автоматически, и состояние программы не утекает в соседние тесты.
Как подменить функцию в тесте pytest?
monkeypatch.setattr(модуль, "имя_функции", заглушка): второй аргумент — объект, третий — имя атрибута строкой, четвёртый — заглушка с той же сигнатурой. Например, monkeypatch.setattr(shop, "get_rate", lambda: 90.0) заменяет чтение курса фиксированным значением до конца теста.
Почему нельзя просто присвоить mymodule.func = stub в тесте?
Прямое присваивание ничего не откатывает: заглушка остаётся в модуле до конца прогона, последующие тесты работают с ней и падают в зависимости от порядка запуска — классические «плавающие» сбои. monkeypatch запоминает оригинал и восстанавливает его гарантированно, даже если тест упал посередине.
Как протестировать код, который читает переменные окружения?
Через monkeypatch.setenv("ИМЯ", "значение") — переменная появится на время теста; через monkeypatch.delenv("ИМЯ", raising=False) — исчезнет. Это изолирует тест от настоящей машины: не важно, задан ли на ней настоящий ключ API. После теста окружение возвращается в исходное состояние.
Чем monkeypatch отличается от библиотеки mock?
unittest.mock — отдельная библиотека с богатыми возможностями: счётчики вызовов, автоспецификация интерфейсов. monkeypatch — примитив pytest: подмена и гарантированный откат, ничего больше. Для большинства тестов этого достаточно и код получается короче; в больших сьютах оба инструмента уживаются рядом.
Понравился урок? Сошлитесь на него
«Подмена живёт ровно один тест и стирается сама, независимо от того, прошёл тест или упал.»
Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
pytest · Урок 11
Встроенные фикстуры: capsys и tmp_path
Половину бытовых задач тестирования закрывают две фикстуры из коробки: capsys читает то, что функция напечатала, tmp_path выдаёт чистую временную папку — без декораторов и настроек.
pytest · Урок 13
Метки: skip, xfail и свои mark
Не каждый тест обязан бежать в каждом прогоне: пометь его skip с причиной, xfail для известного бага или своей меткой — и управляй набором как меню, а не как свалкой.
pytest · Урок 20
Финальный проект: полный тест-сьют модуля работы с заказами
Выпускной: модуль заказов со скидками, классом Order, JSON и валютой — и полный сьют из шестнадцати тестов, который держит его весь целиком и гоняется одной командой.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
Flask · Урок 15
Тестирование Flask: pytest и test_client
Пятнадцатый урок курса Flask — мост в мир автотестов: проверки API из предыдущих уроков становятся тест-функциями pytest с assert, а app.test_client() работает внутри теста без сервера и сети.
flask тестирование pytest test_clientтестирование flask api pytest
Flask · Урок 16
Фикстуры для Flask-тестов: conftest, клиент и временная база
Шестнадцатый урок расширения Flask: каждый тест создаёт клиент сам — пора зафиксировать подготовку в фикстурах, вынести её в conftest.py и раздавать тестам временные базы через tmp_path.
pytest flask фикстуры conftestpytest fixture test_client
pytest · Урок 2
Как pytest находит тесты: конвенции имён файлов и функций
pytest не читает мысли: тестом становится только то, что названо по конвенции — test_*.py, test_-функции, классы Test*. И адрес одного теста: файл::тест.
pytest как запускать тестыpytest конвенции имён