Моки, патчи и тестовые двойники

⏱ 40 мин · 🧩 заданий: 5

Зачем моки

Модульный тест должен быть быстрым, детерминированным и не зависеть от внешнего мира. Но код ходит в сеть, базу, почту, читает текущее время и генерирует случайные числа. Такие зависимости в тестах подменяют:

  • реальный 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

Вы не вошли — ответы проверяются, но прогресс не сохранится. Войти или зарегистрироваться.
Задание 1 Программа на Python

Функция 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

Задание 2 Программа на Python

Напишите тест 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"

Задание 3 Один вариант ответа

Модуль shop/orders.py содержит from shop.payments import charge и вызывает charge(...). Какой патч подменит вызов в тесте create_order?

Задание 4 Заполнить таблицу

Какой это тестовый двойник?

stubfakespy / mockdummy
Объект, который просто передаётся, чтобы заполнить параметр, и не используется
Возвращает заранее заданный курс валют 90.5
Хранилище пользователей в словаре вместо PostgreSQL
Записывает вызовы, чтобы потом проверить, что письмо отправлено один раз
Отметьте ячейку — «+», пустая — «−».
Задание 5 Один вариант ответа

Зачем передавать spec= (или использовать autospec=True) при создании мока?

Войдите, чтобы отмечать пройденные уроки.