Stub, mock, fake и spy; unittest.mock, правило «где патчить», autospec, AsyncMock
- Маршрут: Работа в эксплуатации · тема 1 из 6
- Навыки:
M6- До урока:
python_testing,oop,context_managers.- Результат: тест проверяет поведение модуля, не обращаясь к сети, почте, часам и файловой системе.
- Основа: заглушка и подмена зависимости,
unittest.mock, правило «где патчить». Работа в эксплуатации:autospec, фейки вместо моков, контрактные тесты границы. Углубление:AsyncMockи отмена; вернитесь послеasync_python.- Подтверждение: набор тестов, доказывающий число обращений к внешней системе и обработку её отказа, без единого сетевого вызова.
Тестовый двойник — это подставной объект вместо настоящей зависимости. Он нужен не для скорости самой по себе, а чтобы тест давал одинаковый ответ независимо от того, работает ли почтовый сервер и какое сегодня число.
Продолжаем модуль расчёта заказа из темы python_testing. Теперь у него есть
две внешние зависимости: курс валют по HTTP и отправка чека по почте.
# shop/checkout.py
import requests
from shop.orders import order_total
RATE_URL = "https://api.example.com/rates"
def fetch_rate(currency: str) -> float:
resp = requests.get(RATE_URL, params={"to": currency}, timeout=5)
resp.raise_for_status()
return resp.json()["rate"]
def checkout(items, promo, promos, email, currency, mailer) -> float:
total = order_total(items, promo, promos)
if currency != "RUB":
total = round(total * fetch_rate(currency), 2)
mailer.send(to=email, subject="Ваш заказ", body=f"Итого: {total}")
return totalБез двойников тест этой функции обращается к чужому серверу, зависит от сегодняшнего курса и рассылает письма на реальные адреса.
Слово «мок» в разговоре означает любой двойник, но у видов разное назначение, и смешивать их — источник хрупких тестов.
| Вид | Что делает | Проверяет |
|---|---|---|
| Stub (заглушка) | возвращает заготовленный ответ | результат вызывающего кода |
| Mock | записывает вызовы и позволяет их проверить | взаимодействие на границе |
| Fake (фейк) | рабочая упрощённая реализация | результат и накопленное состояние |
| Spy (шпион) | пропускает вызов в настоящий объект и считает вызовы | что настоящий код был вызван |
Практическое правило: если поведение видно в возвращаемом значении или состоянии, проверяйте его, а не вызовы. Проверка вызовов нужна там, где результата нет — отправленное письмо, списание денег, запись в очередь.
unittest.mock: заготовленные ответыMock создаёт атрибуты и методы по требованию: любое обращение возвращает новый
Mock, а не AttributeError.
from unittest.mock import Mock
mailer = Mock()
mailer.send(to="a@example.com", subject="Ваш заказ", body="Итого: 90.0")
mailer.send.assert_called_once_with(
to="a@example.com", subject="Ваш заказ", body="Итого: 90.0",
)
assert mailer.send.call_count == 1return_value задаёт ответ, side_effect — последовательность ответов или
исключение:
rates = Mock(return_value=0.011)
assert rates("USD") == 0.011
flaky = Mock(side_effect=[TimeoutError, TimeoutError, 0.011])
# первые два вызова поднимут TimeoutError, третий вернёт курс
broken = Mock(side_effect=ConnectionError("сеть недоступна"))side_effect со списком — основной инструмент проверки повторов: он задаёт
сценарий «два отказа, потом успех» без единого настоящего ожидания.
MagicMock дополнительно поддерживает магические методы, поэтому годится там,
где объект используют в len(), with или сравнениях:
from unittest.mock import MagicMock
conn = MagicMock()
with conn: # __enter__/__exit__ уже определены
conn.execute("SELECT 1")Полезные проверки вызовов:
mock.assert_called() # был хотя бы один вызов
mock.assert_called_once() # ровно один
mock.assert_called_once_with(1, key="v") # один, с этими аргументами
mock.assert_any_call(2) # такой вызов был среди прочих
mock.assert_not_called()
mock.call_args # аргументы последнего вызова
mock.call_args_list # все вызовы по порядкуЗаметьте: assert_called_once_with — метод двойника, а не assert. Опечатка в
имени (assert_called_once_wiht) создаст новый атрибут-мок, и тест пройдёт, ничего
не проверив. Защита от этого — в разделе 4.
patch: подменять там, где используютpatch временно заменяет объект по строковому пути. Главная ошибка — патчить
модуль, где функция определена, вместо модуля, где её вызывают.
shop/checkout.py делает import requests и вызывает requests.get. Значит,
подменять нужно shop.checkout.requests.get, а не requests.get вообще:
from unittest.mock import patch
@patch("shop.checkout.requests.get")
def test_fetch_rate_reads_json(mock_get):
mock_get.return_value.json.return_value = {"rate": 0.011}
mock_get.return_value.raise_for_status.return_value = None
assert fetch_rate("USD") == 0.011
mock_get.assert_called_once_with(
"https://api.example.com/rates", params={"to": "USD"}, timeout=5,
)Если бы модуль писал from requests import get, имя get было бы связано в
пространстве имён shop.checkout в момент импорта, и патчить пришлось бы
shop.checkout.get. Правило одно: путь для patch — это то, как имя видно из
тестируемого модуля.
patch работает декоратором и контекстным менеджером; во втором виде область
подмены видна точнее:
def test_checkout_sends_receipt():
with patch("shop.checkout.fetch_rate", return_value=0.011) as rate:
mailer = Mock()
total = checkout([(100.0, 1)], None, {}, "a@example.com", "USD", mailer)
assert total == pytest.approx(1.1)
rate.assert_called_once_with("USD")
mailer.send.assert_called_once()При нескольких декораторах аргументы идут снизу вверх — ближний к функции декоратор соответствует первому параметру:
@patch("shop.checkout.requests.get") # → mock_get (второй параметр)
@patch("shop.checkout.time.sleep") # → mock_sleep (первый параметр)
def test_retry(mock_sleep, mock_get):
...Для атрибутов и окружения в pytest вместо patch удобнее monkeypatch — он
снимает подмену сам, без вложенных with:
def test_rate_url_from_env(monkeypatch):
monkeypatch.setenv("RATE_URL", "https://test.local/rates")
monkeypatch.setattr("shop.checkout.fetch_rate", lambda currency: 0.011)autospec: двойник, который знает сигнатуруОбычный Mock принимает любой вызов. Это значит, что тест продолжит проходить
после переименования метода или изменения его аргументов — то есть перестанет
защищать ровно тогда, когда нужнее всего.
mailer = Mock()
mailer.sedn(to="a@example.com") # опечатка: метода sedn не существует
mailer.send(recipient="a@example.com") # неверное имя аргумента
# оба вызова успешны — двойник ничего не проверяетcreate_autospec и patch(..., autospec=True) строят двойник по настоящему
объекту: имена методов и сигнатуры проверяются как у оригинала.
from unittest.mock import create_autospec
from shop.mail import SmtpMailer
mailer = create_autospec(SmtpMailer, instance=True)
mailer.send(to="a@example.com", subject="s", body="b") # ок
mailer.sedn(to="a@example.com")
# AttributeError: Mock object has no attribute 'sedn'
mailer.send(recipient="a@example.com")
# TypeError: missing a required argument: 'to'@patch("shop.checkout.requests.get", autospec=True)
def test_fetch_rate(mock_get):
...Используйте autospec=True по умолчанию. Единственная цена — двойник придётся
обновлять вместе с настоящим интерфейсом, но это и есть польза.
Когда зависимость вызывают многократно и важен накопленный результат, мок превращается в набор хрупких проверок вызовов. Фейк — маленькая настоящая реализация — читается лучше и не ломается при перестановке вызовов.
class FakeMailer:
"""Рабочая реализация почты в памяти."""
def __init__(self):
self.sent = []
def send(self, to: str, subject: str, body: str) -> None:
if "@" not in to:
raise ValueError(f"некорректный адрес: {to}")
self.sent.append({"to": to, "subject": subject, "body": body})
def test_checkout_sends_one_receipt(promos):
mailer = FakeMailer()
checkout([(100.0, 1)], "WELCOME", promos, "a@example.com", "RUB", mailer)
assert len(mailer.sent) == 1
assert mailer.sent[0]["to"] == "a@example.com"
assert "90.0" in mailer.sent[0]["body"]Фейк проверяет и то, чего мок не заметит: некорректный адрес поднимет ошибку так же, как в эксплуатации. Держите фейк рядом с настоящей реализацией и покрывайте их одним набором тестов — тогда фейк не разойдётся с оригиналом.
mocker.spy из pytest-mock оборачивает существующий метод: он выполняется
по-настоящему, но вызовы записываются.
uv add --dev pytest-mockimport shop.orders
def test_promos_are_read_from_disk_once(mocker, promo_file):
spy = mocker.spy(shop.orders, "load_promos")
promos = shop.orders.load_promos(promo_file)
assert promos == {"WELCOME": 10, "SALE50": 50} # настоящий результат
assert spy.call_count == 1 # и учтённый вызовШпион уместен, когда нужно убедиться, что дорогая операция выполняется столько раз, сколько задумано — например, что кеш действительно избавляет от повторного чтения. Подменять поведение он при этом не должен.
pytest-mock даёт те же возможности через фикстуру mocker, снимая подмены
автоматически:
def test_checkout_with_mocker(mocker):
rate = mocker.patch("shop.checkout.fetch_rate", autospec=True, return_value=0.011)
mailer = mocker.Mock()
checkout([(100.0, 1)], None, {}, "a@example.com", "USD", mailer)
rate.assert_called_once_with("USD")Для корутин нужен AsyncMock: он возвращает объект, который можно ожидать, и
даёт проверки assert_awaited*. Обычный Mock в await не годится.
from unittest.mock import AsyncMock
import pytest
async def test_async_checkout(mocker):
fetch = mocker.patch(
"shop.checkout.fetch_rate_async", new_callable=AsyncMock, return_value=0.011,
)
total = await checkout_async([(100.0, 1)], None, {}, "a@example.com", "USD")
assert total == pytest.approx(1.1)
fetch.assert_awaited_once_with("USD")Для функции, объявленной через async def, достаточно patch(..., autospec=True):
двойник сам получится ожидаемым, с рабочими проверками assert_awaited*, и
отдельный new_callable не нужен.
Асинхронная фикстура объявляется через pytest_asyncio.fixture; @pytest.fixture
в строгом режиме pytest-asyncio вернёт незапущенный генератор:
import pytest_asyncio
@pytest_asyncio.fixture
async def client():
async with make_client() as c:
yield cРежимы pytest-asyncio (strict и auto), управление циклом событий и
проверки отмены разобраны в теме async_python. Вернитесь к этому разделу после
неё и добавьте к набору проверки отмены и очистки.
Не мокайте то, что не принадлежит вам, без контрактного теста. Двойник чужого HTTP-API фиксирует ваши представления о нём, а не его поведение. Когда поставщик поменяет формат ответа, тесты останутся зелёными, а эксплуатация сломается. Держите отдельный небольшой тест, который ходит в настоящий сервис (или в его официальную песочницу) и проверяет форму ответа.
Не мокайте то, что тестируете. Если в тесте checkout подменены и
order_total, и fetch_rate, и mailer, проверяется только порядок вызовов —
то есть текущая реализация, а не поведение.
Не мокайте дешёвое и детерминированное. Чистой функции, словарю или
dataclass двойник не нужен: настоящий объект и быстрее, и честнее.
Не проверяйте вызовы там, где виден результат. Тест, требующий точной последовательности внутренних вызовов, запрещает безопасный рефакторинг — переставили две строки, набор покраснел, поведение не изменилось.
# Проблема: тест закрепляет реализацию
def test_checkout(mocker):
total_mock = mocker.patch("shop.checkout.order_total", return_value=90.0)
...
total_mock.assert_called_once()
# Как надо: проверяем наблюдаемый результат и границу с внешним миром
def test_checkout_charges_discounted_total(promos):
mailer = FakeMailer()
total = checkout([(100.0, 1)], "WELCOME", promos, "a@example.com", "RUB", mailer)
assert total == pytest.approx(90.0)
assert len(mailer.sent) == 1Чем больше двойников в тесте, тем меньше он говорит о работоспособности системы. Если для одного теста нужно подменить пять зависимостей, это сигнал о связанности кода, а не о нехватке моков.
Возьмите функцию с одной внешней зависимостью и напишите двойник вручную —
обычным классом с методом-заглушкой, без unittest.mock. Это делает видимым,
что двойник — просто объект нужной формы.
class RecordingMailer:
def __init__(self):
self.calls = []
def send(self, to, subject, body):
self.calls.append(to)
mailer = RecordingMailer()
checkout([(100.0, 1)], None, {}, "a@example.com", "RUB", mailer)
assert mailer.calls == ["a@example.com"]Замените ручной двойник на create_autospec(SmtpMailer, instance=True) и
убедитесь, что тест ловит опечатку в имени метода. Затем добавьте сценарий
отказа: side_effect=[TimeoutError, TimeoutError, 0.011] и проверка, что код
сделал ровно три попытки и не ждал по-настоящему.
Объясните, почему patch("requests.get") и patch("shop.checkout.requests.get")
дают разный результат, и что происходит с пространством имён модуля при
from ... import .... Затем возьмите один из своих тестов и назовите изменение
кода, которое его сломает, не сломав поведения, — это цена проверки вызовов.
fetch_rate, подменив requests.get так, чтобы проверялись и URL, и параметры, и таймаут.patch по месту определения функции не срабатывает, и покажите оба пути импорта.Mock() на create_autospec(..., instance=True) и найдите хотя бы один вызов, который раньше проходил ошибочно.FakeCache со словарём внутри и проверьте им поведение при повторном запросе, не проверяя вызовы.side_effect=[ConnectionError, ConnectionError, {"rate": 0.011}], доказывающий число попыток и отсутствие настоящего ожидания.assert_called_once_with подряд, и перепишите его на проверку наблюдаемого результата. Объясните, что потеряно и что приобретено.Mock на AsyncMock в тесте корутины и объясните разницу между assert_called_once и assert_awaited_once.Тема закрепляется лабораторной работой из python_testing —
«Проверка повторов через внесение сбоев».
Она целиком построена на сценарном двойнике: он должен доказать точное число
попыток, задержки, распространение неожиданных ошибок и отсутствие настоящего
ожидания. Если вы проходили её до этого урока, вернитесь и замените ручные
двойники на create_autospec.
Далее: Стратегия тестового набора