Набору юнит-тестов исполнилось три года. Восемьсот проверок, зелёный конвейер, приятные секунды прогона. В тот же месяц поддержка приняла жалобы на зависшие оплаты, и разбор показал неприятную вещь: внешний провайдер давно сменил формат ответа, а набор продолжал жить в мире трёхлетней давности, потому что этот мир целиком описывали его собственные заглушки. Подмены в тестах удобны и коварны одновременно, их возраст редко считают активом, чаще просто забывают про него.

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

Подмены в тестах различаются поведением и назначением

Словарь подмен разобрал Жерар Мезарош в справочнике паттернов 2007 года, а разъяснение Фаулера в январе того же года закрепил различие в массовой практике. Пустышка просто заполняет параметр, к ней никто не обращается. Заглушка отдаёт заученные ответы на заученные вопросы. Подделка работает по-настоящему в упрощённом виде, у неё есть собственная логика. Шпион записывает обращения, чтобы проверить их позже. Мок, в строгом смысле, проверяет ожидания вызовов, то есть заранее объявляет, кто, кого и сколько раз должен позвать.

Из этого словаря вырастает главное различие в способе проверки. Проверка состояния смотрит на результат работы, проверка поведения смотрит на сам разговор объектов. Вторая теснее связана с устройством кода, и Фаулер посвятил этому целую главу про сцепление тестов с реализацией. Подделка с поведением обычно честнее заглушки, и вот почему:

class FakeCardGateway:
    def __init__(self):
        self.charged = []

    def charge(self, card_id, amount):
        if amount < 0:
            raise ValueError("negative amount")
        self.charged.append((card_id, amount))
        return "CHARGED"

У такой подделки есть собственные правила, она способна упасть сама, и тест на списание средств проверяет реакцию сервиса на настоящую логику соседа, а не на магнитофон с одной записью.

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

Через три года набор хранит следы реализации

Проверка вызовов копирует устройство класса в тест. Смотри типовой фрагмент:

def test_checkout_notifies_warehouse(mocker):
    warehouse = mocker.Mock()
    service.checkout(order_id="A-77")
    warehouse.ship.assert_called_once_with(order_id="A-77", lane="standard")

Пока поведение совпадает с механикой вызова, всё здорово. Потом приходит обычный рефакторинг: поле lane переименовали в delivery_class, извлекли проверку маршрута в отдельный модуль, слили два вызова в пакетный. Поведение для пользователя не изменилось ни на йоту, а набор краснеет в шести файлах. Со временем это меняет культуру команды: правки внутри модуля начинают пугать, тесты обхаживают вместо переписывания, а ветки с пометкой временно отключено копятся годами.

Второй аспект сцепления проявляется в проверках структуры. Тест заглядывает во внутренние поля, дёргает приватные методы через обходные приёмы, привязывается к структуре каталогов. Каждый такой тест переживает переезд файла как переезд семьи, и со временем таких проверок набирается столько, что половина набора охраняет не поведение, а планировку.

Частичная защита встроена в сам инструмент. Питоновский create_autospec строит подмену по сигнатуре настоящего класса и ловит расхождение параметров на месте:

warehouse = mocker.create_autospec(Warehouse)

Автоспецификация ловит опечатку в имени аргумента, но не ловит переименование смысла: параметр с тем же именем и другим значением пройдёт мимо. Рабочее правило звучит жёстче: проверять вызовы можно, но редкими мазками, на границах модулей и только там, где сам порядок обращений и есть контракт.

Старение набора заметно по трём признакам. Имена описывают методы, а не поведение: test_save_calls_save вместо test_paid_order_becomes_shipped. Фикстуры переехали в общий файл на тысячу строк, где правка одного кейса ломает пятнадцать. И главный симптом, страх: ветка с рефакторингом висит неделю, потому что автор уже два дня красит набор. Типовая сценка из практики: команде поручили обновление библиотеки сериализации, набор покраснел в десятках файлов, два дня дежурства не нашли ни одного настоящего дефекта, только устаревшие ожидания вызовов.

Заглушки дрейфуют от реального контракта внешнего сервиса

Самая дорогая ловушка зреет тихо. В 2021 году в фикстуры положили ответ провайдера:

def provider_answer(status="SUCCESS"):
    return {"order_id": "A-77", "status": status}

В 2023 году поставщик сменил значение статуса на OK, добавил поле с версией контракта и начал слать иной формат сумм. Фикстуры продолжали возвращать старую строку, юнит-набор видел только свой собственный ответ, проверка обработки нового статуса не существовала вовсе. Набор оставался зелёным все эти месяцы, конвейер гордился скоростью, а оплата зависала в проде по классической причине: тестовый мир описывался теми же руками, что и код.

Лечение состоит в том, чтобы заглушки рождались не из памяти разработчика. Схема внешнего сервиса превращается в типы и примеры запросов автоматически, тогда фикстура обновляется вместе со схемой. Контрактные проверки сверяют ожидания клиента с настоящими ответами провайдера, инструменты вроде Pact делают это между сборками двух команд. На стенде живёт сервис-двойник, собранный из актуальной схемы, и любой дрейф контракта всплывает в нём за минуты, а не в отчётах поддержки.

Помощь приходит и со стороны типов. В проектах на TypeScript клиентские типы генерируются из схемы внешнего сервиса, и устаревший пример ответа ловит компилятор до запуска тестов. В Python ту же нишу занимают строгие модели из pydantic: пример ответа парсится жёсткой моделью при каждом прогоне, лишнее поле или пропавшее значение роняют проверку. Дешёвая привычка дополняет картину: поле с версией контракта в каждом ответе превращает тихий дрейф в громкое исключение, которое невозможно пропустить. Бонус контрактного подхода в экономии совещаний, спор о формате ответа разрешается ссылкой на схему, а не перепиской в чате.

Чего моки не замечают в запросах и транзакциях

Подмена репозитория выключает целый класс дефектов из поля зрения. Юнит-набор с идеальной заглушкой не исполняет SQL, поэтому не видит ни сто двадцать один запрос на странице списка, ни отсутствующий индекс, ни блокировку двух записей в одном порядке, ни конфликт уникальности при параллельной вставке, ни разъехавшиеся часовые пояса в сериализации дат. Всё это живёт только при настоящем движке, и никакая аккуратность моков не поможет.

Страховка стоит дёшево, если сценарий уже подключает реальную СУБД в контейнере:

def test_order_page_runs_three_queries(django_assert_num_queries):
    with django_assert_num_queries(3):
        api.get("/orders/A-77")

Счётчик запросов из pytest-django превращает деградацию производительности в красную строку набора, задолго до первых жалоб пользователей. Тот же принцип работает для очередей и тайм-аутов: порядок сообщений, повторные доставки, обрыв соединения посреди транзакции. Заглушки передают это поведение только руками, а руки устают и путаются.

Время подменяет картину целиком. Заглушки отвечают мгновенно, тайм-ауты в тестах не наступают никогда, и код, который не умеет ждать, виден только в сценарии с настоящим узлом. Повторные доставки, идемпотентность обработчика, обрыв соединения посреди записи, всё это свойства среды, а не логики, и среда обязана быть настоящей хотя бы в одном месте набора.

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

Интеграционные сценарии как вторая половина комплекта

Два уровня отвечают на разные вопросы. Юнит-набор с подменами доказывает, что логика верна в изоляции. Интеграционный сценарий доказывает, что система верна на стыках: оплаты приходят, статусы сходятся, письма уходят после коммита транзакции, а не до него. Ошибиться в формулировке вопроса проще, чем кажется: вопрос "логика верна" не покрывает вопрос "система жива", и три года зеленого конвейера из вступительной сцены это показали.

Рабочий сценарий выглядит буднично и ценен именно этим:

def test_paid_order_reaches_shipment(postgres, provider_stub):
    provider_stub.queue({"order_id": "A-77", "status": "OK"})
    api.post("/orders/A-77/confirm")
    assert shipment_of("A-77") == "ready"

Здесь настоящая СУБД, настоящий обработчик запроса и двойник провайдера, собранный из текущей схемы. Такой тест дешевле одной ночной отладки зависших платежей. Сценариев не должно быть много: критичные пользовательские пути, денежные и статусные переходы, стыки с внешними системами. Всё остальное остаётся в быстрых юнитах, иначе конвейер потеряет скорость, ради которой подмены и заводили.

Сценарии собирают вокруг пользовательских историй, а не вокруг классов: оформленная корзина, возврат товара, продление подписки после простоя. Один такой тест покрывает дюжину юнит-кейсов и живёт дольше их всех, потому что история пользователя меняется куда медленнее внутренней механики. Владелец обязателен: сценарий без автора умирает первым при первой поломке, и чинят его всегда за причину, а не за симптом.

Правила, которые держат подмены честными годами

Рабочие правила, собранные из чужих шишек, умещаются в короткий перечень:
1. подменяй на границах архитектуры, у швов модулей, а не на внутренностях класса;
2. предпочитай подделку с поведением заглушке с заученными ответами;
3. порождай фикстуры из схемы внешнего сервиса и обновляй их при смене контракта;
4. прикрывай расхождение контракта отдельной проверкой, а не надеждой на внимательного коллегу;
5. ограничивай проверки вызовов одним-двумя на модуль и проверяй следствие, а не механику;
6. при рефакторинге правь тесты вместе с кодом, удаляй устаревшие фикстуры, архив истории не актив.

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

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

Вывод о возрасте набора и распределении доверия

Мок оправдан как изоляция и осуждаем как замена реальности. С возрастом набора подмены накапливают следы реализации и тихо дрейфуют от живых контрактов, а проверить их честность изнутри невозможно: заглушка подтверждает сама себя. Возраст набора честно отражается в его словаре: свежие подмены говорят языком контрактов, старые языком былой реализации. Правило ухода простое: раз в квартал просматривать фикстуры на предмет полей, которых нет в актуальной схеме, и удалять без сожалений. Свежую правду о стыках приносят только интеграционные сценарии и контрактные проверки, и без этой пары юнит-набор медленно превращается в зеркало собственных предположений.

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