Перейти к содержанию

192 уроков, 14 библиотек и челлендж «Что выведет код?» — бесплатно, код прямо в браузере

Начать обучение
Урок 12 из 20 Средний 35 мин 130 XP

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. Второй тест убеждается, что случайность вернулась на место: заглушка не пережила свой тест.

без monkeypatch: откат руками
def test_lucky_manual():
    original = random.choice
    random.choice = lambda seq: 7
    try:
        assert lottery.lucky_number() == 7
    finally:
        random.choice = original
То же самое в ручном исполнении: сохранить оригинал, подменить, вернуть в finally при любом исходе. monkeypatch сжимает эти четыре служебные строки в один вызов и не забывает finally за тебя.

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", заглушка) — путь «модуль.атрибут» одной строкой. Она короче, но прячет зависимость от строковых имён: опечатка всплывёт только при запуске. Форма с объектом предпочтительнее — подсказки редактора работают, а переименование в модуле сломает тест на этапе импорта.

строковая форма setattr
def test_rate_string_form(monkeypatch):
    monkeypatch.setattr("shop.get_rate", lambda: 90.0)
    assert shop.to_rub(2) == 180.0
Фрагмент тестового файла: путь «модуль.атрибут» одной строкой делает то же, что setattr(shop, "get_rate", ...). Импорт shop в тесте всё равно нужен — вызывать to_rub придётся по нему.

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", "нет"))
Проверь себя
0 / 6

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-ом?

Карточки терминов
Запомнено: 0 / 6
Практика

Модуль auth содержит функцию read_api_key(), которая читает переменную окружения API_KEY. Напиши тест с monkeypatch.setenv: подставь ключ test-key-123 и проверь, что read_api_key() возвращает именно его.

practice.py
Вопросы и ответы по уроку

Что такое 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-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Похожие уроки по темам

Подобраны автоматически по пересечению тем и ключевых слов.