Перейти к основному контенту
Tech Path Finder
КурсыИнтервьюКод-ревьюБлог
Tech Path Finder

Персонализированный путеводитель в IT. Квизы, мок-интервью, код ревью и аналитика прогресса.

@potapov_me

Платформа

  • Курсы
  • Прогресс
  • Мок-интервью
  • Код ревью
  • Живое ревью с ИИ
  • Тренажёр переговоров
  • Закладки

Контент

  • Блог
  • Главная
  • Обратная связь

Компания

  • О проекте
  • Тарифы
  • Условия использования
  • Конфиденциальность
  • Согласие на обработку данных
  • Cookie
  • Реквизиты

Аккаунт

  • Войти
  • Зарегистрироваться
  • Профиль

© 2026 Tech Path Finder. Все права защищены.

·ИП Потапов К.С.·Политика конфиденциальности·
Сделано с ❤️ в России
  1. Тестовые двойники: изоляция зависимостей
test_doubles

Тестовые двойники: изоляция зависимостей

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

Без двойников тест этой функции обращается к чужому серверу, зависит от сегодняшнего курса и рассылает письма на реальные адреса.

#1. Виды двойников

Слово «мок» в разговоре означает любой двойник, но у видов разное назначение, и смешивать их — источник хрупких тестов.

ВидЧто делаетПроверяет
Stub (заглушка)возвращает заготовленный ответрезультат вызывающего кода
Mockзаписывает вызовы и позволяет их проверитьвзаимодействие на границе
Fake (фейк)рабочая упрощённая реализациярезультат и накопленное состояние
Spy (шпион)пропускает вызов в настоящий объект и считает вызовычто настоящий код был вызван

Практическое правило: если поведение видно в возвращаемом значении или состоянии, проверяйте его, а не вызовы. Проверка вызовов нужна там, где результата нет — отправленное письмо, списание денег, запись в очередь.

#2. 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 == 1

return_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.

#3. 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)

#4. 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 по умолчанию. Единственная цена — двойник придётся обновлять вместе с настоящим интерфейсом, но это и есть польза.

#5. Фейк вместо мока

Когда зависимость вызывают многократно и важен накопленный результат, мок превращается в набор хрупких проверок вызовов. Фейк — маленькая настоящая реализация — читается лучше и не ломается при перестановке вызовов.

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"]

Фейк проверяет и то, чего мок не заметит: некорректный адрес поднимет ошибку так же, как в эксплуатации. Держите фейк рядом с настоящей реализацией и покрывайте их одним набором тестов — тогда фейк не разойдётся с оригиналом.

#6. Шпион: настоящий код плюс учёт вызовов

mocker.spy из pytest-mock оборачивает существующий метод: он выполняется по-настоящему, но вызовы записываются.

uv add --dev pytest-mock
import 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")

#7. Асинхронные двойники

Для корутин нужен 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. Вернитесь к этому разделу после неё и добавьте к набору проверки отмены и очистки.

#8. Когда не надо мокать

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

Чем больше двойников в тесте, тем меньше он говорит о работоспособности системы. Если для одного теста нужно подменить пять зависимостей, это сигнал о связанности кода, а не о нехватке моков.

#Практика: от пробы к рабочему решению

#Шаг 1. Соберите минимальный пример

Возьмите функцию с одной внешней зависимостью и напишите двойник вручную — обычным классом с методом-заглушкой, без unittest.mock. Это делает видимым, что двойник — просто объект нужной формы.

#Шаг 2. Проверьте поведение

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"]

#Шаг 3. Доведите решение до рабочего сценария

Замените ручной двойник на create_autospec(SmtpMailer, instance=True) и убедитесь, что тест ловит опечатку в имени метода. Затем добавьте сценарий отказа: side_effect=[TimeoutError, TimeoutError, 0.011] и проверка, что код сделал ровно три попытки и не ждал по-настоящему.

#Шаг 4. Объясните внутренний механизм

Объясните, почему patch("requests.get") и patch("shop.checkout.requests.get") дают разный результат, и что происходит с пространством имён модуля при from ... import .... Затем возьмите один из своих тестов и назовите изменение кода, которое его сломает, не сломав поведения, — это цена проверки вызовов.

#Упражнения

  1. Напишите тест fetch_rate, подменив requests.get так, чтобы проверялись и URL, и параметры, и таймаут.
  2. Объясните на своём проекте, почему patch по месту определения функции не срабатывает, и покажите оба пути импорта.
  3. Переведите три теста с Mock() на create_autospec(..., instance=True) и найдите хотя бы один вызов, который раньше проходил ошибочно.
  4. Реализуйте FakeCache со словарём внутри и проверьте им поведение при повторном запросе, не проверяя вызовы.
  5. Напишите тест повторов через side_effect=[ConnectionError, ConnectionError, {"rate": 0.011}], доказывающий число попыток и отсутствие настоящего ожидания.
  6. Возьмите тест, который проверяет три assert_called_once_with подряд, и перепишите его на проверку наблюдаемого результата. Объясните, что потеряно и что приобретено.
  7. Замените Mock на AsyncMock в тесте корутины и объясните разницу между assert_called_once и assert_awaited_once.

#Исполняемая лаборатория

Тема закрепляется лабораторной работой из python_testing — «Проверка повторов через внесение сбоев». Она целиком построена на сценарном двойнике: он должен доказать точное число попыток, задержки, распространение неожиданных ошибок и отсутствие настоящего ожидания. Если вы проходили её до этого урока, вернитесь и замените ручные двойники на create_autospec.


Далее: Стратегия тестового набора