Моки, патчи и тестовые двойники
Зачем моки
Модульный тест должен быть быстрым, детерминированным и не зависеть от внешнего мира. Но код ходит в сеть, базу, почту, читает текущее время и генерирует случайные числа. Такие зависимости в тестах подменяют:
- реальный HTTP → заранее заданный ответ;
- отправку письма → запись «что было бы отправлено»;
datetime.now()→ фиксированное время;- платёжный шлюз → фейк, который всегда «успешно списывает».
Тестовые двойники
| Вид | Что делает | Пример |
|---|---|---|
| Dummy | заполняет параметр, не используется | None, пустой объект |
| Stub | возвращает заготовленные ответы | get_rate() всегда 90.5 |
| Fake | рабочая упрощённая реализация | репозиторий на словаре, SQLite в памяти |
| Spy | реальное поведение + запись вызовов | обёртка, считающая вызовы |
| Mock | записывает вызовы, тест проверяет взаимодействие | sender.send.assert_called_once_with(...) |
unittest.mock.Mock
from unittest.mock import Mock
m = Mock()
print(m.anything, m.any_method(1, x=2)) # любые атрибуты и вызовы — тоже моки
m.get_rate.return_value = 90.5 # stub: что вернуть
print(m.get_rate("USD"))
m.get_rate.assert_called_once_with("USD") # проверить вызов
print(m.get_rate.call_count, m.get_rate.call_args)
print(m.method_calls)
try:
m.get_rate.assert_called_with("EUR")
except AssertionError as e:
print("AssertionError:", str(e).splitlines()[0])
side_effect
from unittest.mock import Mock
api = Mock()
api.fetch.side_effect = TimeoutError("сервер не ответил") # бросить исключение
try:
api.fetch()
except TimeoutError as e:
print("поймали:", e)
api.fetch.side_effect = [1, 2, ValueError("кончились")] # последовательность ответов
print(api.fetch(), api.fetch())
api.double.side_effect = lambda x: x * 2 # вычислять ответ функцией
print(api.double(21))
MagicMock
MagicMock дополнительно настраивает магические методы — его можно использовать как контекстный менеджер, итерировать, брать len:
from unittest.mock import MagicMock
f = MagicMock()
f.__enter__.return_value.read.return_value = "содержимое"
with f as handle:
print(handle.read())
f.__iter__.return_value = iter([1, 2])
print(list(f), len(f))
patch
patch временно подменяет объект по имени на время теста и затем возвращает оригинал:
import sys, types
from unittest.mock import patch
# имитируем два модуля проекта
weather = types.ModuleType("weather")
def get_temperature(city):
raise RuntimeError("реальный HTTP-запрос — в тестах нельзя")
weather.get_temperature = get_temperature
sys.modules["weather"] = weather
advice = types.ModuleType("advice")
exec("from weather import get_temperature\n"
"def what_to_wear(city):\n"
" t = get_temperature(city)\n"
" return 'куртка' if t < 10 else 'футболка'\n", advice.__dict__)
sys.modules["advice"] = advice
from advice import what_to_wear
with patch("weather.get_temperature", return_value=-5): # ✗ не то место
try:
print(what_to_wear("Москва"))
except RuntimeError as e:
print("не сработало:", e)
with patch("advice.get_temperature", return_value=-5) as fake: # ✓ там, где используется
print(what_to_wear("Москва"))
fake.assert_called_once_with("Москва")
print("после with оригинал возвращён:", advice.get_temperature is get_temperature)
Главное правило patch
Патчите имя там, где его ищут, а не там, где оно определено. from weather import get_temperature создал в модуле advice собственное имя — его и надо подменять: patch("advice.get_temperature"). Если же в коде import weather и вызов weather.get_temperature(), то патчат "weather.get_temperature".
Формы patch:
@patch("shop.orders.charge") # декоратор: мок передаётся параметром
def test_order(mock_charge):
mock_charge.return_value = "tx-1"
...
with patch.object(PaymentGateway, "charge", return_value="tx-1"): # атрибут объекта
...
with patch.dict("os.environ", {"API_KEY": "test"}): # элементы словаря
...
В pytest удобнее фикстура monkeypatch или плагин pytest-mock (mocker.patch(...)).
spec и autospec
from unittest.mock import Mock, create_autospec
class EmailSender:
def send(self, to: str, text: str) -> None: ...
loose = Mock()
loose.sned("a@x.ru", "привет") # опечатка в имени метода — и тишина!
loose.send("a@x.ru") # забыли аргумент — тоже тишина
print("обычный Mock ничего не заметил:", loose.method_calls)
strict = create_autospec(EmailSender, instance=True)
try:
strict.sned("a@x.ru", "привет")
except AttributeError as e:
print("AttributeError:", e)
try:
strict.send("только один аргумент")
except TypeError as e:
print("TypeError:", e)
Обычный Mock принимает любое имя и любые аргументы, поэтому тест может «зеленеть» при сломанном коде. create_autospec (или patch(..., autospec=True)) проверяет имена и сигнатуры по настоящему классу.
Время, случайность, HTTP
from datetime import datetime
from unittest.mock import patch
import random
def greeting(now=None): # 1. лучший способ — передать зависимость
now = now or datetime.now()
return "Доброе утро" if now.hour < 12 else "Добрый день"
print(greeting(datetime(2026, 1, 1, 9)), "|", greeting(datetime(2026, 1, 1, 15)))
def roll():
return random.randint(1, 6)
with patch("random.randint", return_value=6): # 2. или патч
print(roll())
random.seed(1) # 3. или фиксированный seed
print([random.randint(1, 6) for _ in range(5)])
Для HTTP есть специализированные инструменты: responses (requests), respx (httpx), pytest-httpx, а для времени — freezegun / time-machine.
Внедрение зависимостей вместо патчей
class FakeSender: # fake: проще и надёжнее мока
def __init__(self):
self.sent = []
def send(self, to, text):
self.sent.append((to, text))
class Registration:
def __init__(self, sender): # зависимость приходит снаружи
self.sender = sender
def register(self, email):
self.sender.send(email, "Добро пожаловать!")
return {"email": email}
sender = FakeSender()
Registration(sender).register("anna@x.ru")
assert sender.sent == [("anna@x.ru", "Добро пожаловать!")]
print(sender.sent)
Если зависимость передаётся в конструктор или функцию, патчи почти не нужны: в тесте просто передают фейк.
Не мокайте всё подряд
Тест, в котором замокано всё, проверяет только то, что код вызывает моки в нужном порядке — и ломается при любом рефакторинге. Мокайте границы системы (сеть, время, внешние сервисы), а свой код тестируйте настоящим. Для базы данных часто лучше реальная тестовая БД, чем мок.
Вопрос на собеседовании
«Чем Mock отличается от MagicMock и где патчить?» — MagicMock дополнительно поддерживает магические методы (__len__, __iter__, __enter__). Патчить надо имя в модуле, где оно используется (patch("orders.charge"), если там from payments import charge). side_effect задаёт исключение, последовательность или функцию, return_value — фиксированный результат.
Итог
- Двойники: dummy, stub, fake, spy, mock — подменяют внешние зависимости.
Mock:return_value,side_effect,assert_called_once_with,call_count,call_args.patchподменяет имя на время теста — там, где оно используется.autospecзащищает от опечаток и неверных сигнатур.- Внедрение зависимостей и фейки часто лучше патчей; мокайте только границы системы.
Практика решено 0 из 5
Функция notify_overdue(users, sender) должна вызвать sender.send(email, text) для каждого
пользователя с overdue=True, где текст — "Оплатите счёт, <имя>!", и вернуть количество отправленных писем.
Тест передаст Mock() вместо настоящего отправителя и проверит вызовы.
Какие тесты проверяют код (1)
from unittest.mock import Mock, call
sender = Mock()
users = [
{"name": "Анна", "email": "a@x.ru", "overdue": True},
{"name": "Боря", "email": "b@x.ru", "overdue": False},
{"name": "Вера", "email": "v@x.ru", "overdue": True},
]
assert notify_overdue(users, sender) == 2
sender.send.assert_has_calls([call("a@x.ru", "Оплатите счёт, Анна!"), call("v@x.ru", "Оплатите счёт, Вера!")])
assert sender.send.call_count == 2
def notify_overdue(users, sender):
count = 0
for user in users:
if user["overdue"]:
sender.send(user["email"], f"Оплатите счёт, {user['name']}!")
count += 1
return count
Напишите тест test_retry() для функции fetch_with_retry(client, url), которая делает до 3 попыток
client.get(url) и при ConnectionError пробует снова.
В тесте создайте Mock, у которого get дважды бросает ConnectionError, а на третий раз
возвращает "OK" (через side_effect), и проверьте результат и число вызовов.
Какие тесты проверяют код (2)
test_retry() import inspect src = inspect.getsource(test_retry) assert "side_effect" in src, "Используйте side_effect для последовательности ответов" assert "call_count" in src or "assert_has_calls" in src, "Проверьте, сколько раз вызывался get"
from unittest.mock import Mock
def fetch_with_retry(client, url, attempts=3):
for i in range(attempts):
try:
return client.get(url)
except ConnectionError:
if i == attempts - 1:
raise
def test_retry():
client = Mock()
client.get.side_effect = [ConnectionError(), ConnectionError(), "OK"]
assert fetch_with_retry(client, "/api") == "OK"
assert client.get.call_count == 3
client.get.assert_called_with("/api")
Модуль shop/orders.py содержит from shop.payments import charge и вызывает charge(...). Какой патч подменит вызов в тесте create_order?
Какой это тестовый двойник?
| stub | fake | spy / mock | dummy | |
|---|---|---|---|---|
| Объект, который просто передаётся, чтобы заполнить параметр, и не используется | ||||
| Возвращает заранее заданный курс валют 90.5 | ||||
| Хранилище пользователей в словаре вместо PostgreSQL | ||||
| Записывает вызовы, чтобы потом проверить, что письмо отправлено один раз |
Зачем передавать spec= (или использовать autospec=True) при создании мока?
Войдите, чтобы отмечать пройденные уроки.