Пятница, вечер. Сборка зелёная, покрытие кода 92 процента, триста юнит-тестов прошли за девять секунд. В понедельник служба поддержки собирает жалобы на неправильные суммы в заказах: расчёт округления проверялся на моках репозитория, а настоящая СУБД округляет иначе, и ни один быстрый тест этого не заметил. Знакомая история? Она прижилась настолько, что стала фольклором индустрии.
Спор о юнит-тестах и интеграционных тестах часто подают как конфликт вкусов. Честнее называть его спором об инженерном бюджете: какие ошибки команда ловит за секунды на своей машине, а какие отдаёт покупателям. Ниже разбор обеих сторон, живых пропорций и настройки конвейера без лозунгов.
Пирамида Майка Кона и её урок о пропорциях слоёв
Идея пирамиды тестирования закрепилась после книги Майка Кона о гибкой разработке. Слои рисуют так: широкое основание из быстрых юнит-тестов, средний слой интеграционных проверок, узкая вершина сквозных тестов интерфейса. Кона сам признавал, что средний слой разработчики чаще всего пропускают, а термин service tests каждая команда понимает по-своему.
Хэм Вокке в статье о практической пирамиде уточняет формулировку: пирамида это метафора, которая группирует тесты по зернистости и подсказывает примерное число проверок на каждом уровне. Там же есть предостережение про перевёрнутую фигуру, рожок с мороженым: когда основную массу составляют медленные сквозные проверки, набор превращается в постоянную головную боль и гоняется непозволительно долго.
Названия слоёв вторичны. Автор статьи прямо советует не привязываться к исходным терминам Кона, а выбрать свои имена, лишь бы словарь был единым в команде. Суть в пропорциях и в цене ошибки, а не в подписях на схеме.
Сильные стороны юнит-теста и границы его зоны контроля
Хороший юнит-тест занимает миллисекунды, проверяет одну ветку поведения и падает так близко к причине, что поиск виновника сводится к чтению одного имени. Побочный эффект приятен: код, который удобно покрывать быстрыми проверками, обрастает швами и границами и становится лучше по структуре.
def apply_discount(amount: int, percent: int) -> int:
if percent < 0 or percent > 90:
raise ValueError("percent must be between 0 and 90")
return amount - amount * percent // 100
import pytest
from billing.discount import apply_discount
def test_ten_percent_discount():
assert apply_discount(2000, 10) == 1800
def test_percent_above_ninety_rejected():
with pytest.raises(ValueError):
apply_discount(2000, 95)
Скорость тут не украшение, а рабочий инструмент. Разработчик гоняет набор после каждой правки, не выходя из контекста задачи, и потому смелее двигает код. Повторяющиеся варианты одного расчёта сворачивают в таблицу параметров, и двадцать однотипных проверок сжимаются в компактную конструкцию:
@pytest.mark.parametrize("amount,percent,expected", [
(2000, 10, 1800),
(2000, 0, 2000),
(500, 90, 50),
])
def test_discount_table(amount, percent, expected):
assert apply_discount(amount, percent) == expected
Имена пишут поведением, а не списком методов: test_percent_above_ninety_rejected озвучивает правило бизнеса. Через год такое имя скажет сопровождающему больше, чем история правок. Однострочная проверка с загадочным названием экономит секунды сегодня и крадёт часы завтра.
Структура таких проверок держится на тройке arrange, act, assert: подготовили вход, вызвали поведение, сверили результат. В статье о практической пирамиде разводят два стиля: общительные юнит-тесты работают с настоящими соседями по объекту, одиночные подменяют всё вокруг тестируемого кода. Оба легальны, но одиночный стиль легко скатывается в проверку моков вместо проверки поведения.
Слепая зона юнит-уровня шире, чем кажется: SQL-диалект, блокировки, порядок накатывания миграций, кодировки, сериализация дат, поведение кеша, конкурентные записи. Если поле таблицы переименовали из amount в total_amount, юнит-тест с фальшивым репозиторием останется зелёным. Фальшивке всё равно, какое имя носит колонка.
Есть и скрытая статья расходов: каждый юнит-тест это строка кода, которую сопровождают годами. Набор из тысяч мгновенных проверок с туманными именами и тройной вложенностью фикстур обходится дороже, чем кажется в момент написания.
Интеграционные тесты ловят ошибки на стыках подсистем
Интеграционный тест подключает настоящие узлы: живую базу в контейнере, настоящий Redis, реальный вызов соседнего сервиса по HTTP. Именно этот уровень замечает переименованное поле, отсутствующий индекс, нарушенное ограничение уникальности, разъехавшиеся часовые пояса, молчащую кодировку.
def test_saved_order_roundtrip(postgres):
repo = OrderRepository(postgres.url)
order = Order(items=[Item(sku="A-1", qty=3, price=250)])
repo.save(order)
loaded = repo.find(order.id)
assert loaded.total == 750
assert loaded.items[0].sku == "A-1"
Цена честная: секунды вместо миллисекунд, потребность в окружении, конфликты параллельных прогонов. Герметичность снимает страхи. База поднимается контейнером на каждую сессию, накатываются реальные миграции, после прогона контейнер уничтожается, повторяемость почти идеальная. Правило без исключений: движок СУБД в тестах совпадает с боевым. Встроенная в память база прячет диалектные особенности и дарит ложное спокойствие.
docker compose up -d postgres-test redis-test
pytest tests/integration -m integration -n 4 --durations=10
Флаг -n включает четыре параллельных воркера из пакета pytest-xdist. В мире JVM ту же нишу занимает библиотека testcontainers, она поднимает PostgreSQL, Kafka и другие узлы прямо из теста. Подход одинаков везде: медленная проверка с настоящим окружением дешевле одного разбора инцидента.
Управление состоянием решает половину проблем среднего слоя. Самый дешёвый приём заворачивать каждый тест в транзакцию и откатывать её после проверки: таблицы остаются чистыми без ожидания усечения, а схема накатывается миграциями один раз за сессию:
@pytest.fixture
def db(postgres):
with psycopg.connect(postgres.url) as conn:
yield conn
conn.rollback()
Для кеша и очередей работает та же изоляция: ключам тестового прогона добавляют префикс с идентификатором сборки, и параллельные ветки не спорят за общий Redis. Сорок интеграционных проверок на свежих контейнерах обычно укладываются в четыре-шесть минут. Плата заметная, но она оправдана: стыки проверяются в боевом окружении, а не в воображении разработчика.
Моки на каждом уровне и обман красивого процента покрытия
Модная крайность выглядит эффектно: каждый коллаборатор заменён моком, покрытие 100 процентов, конвейер всегда зелёный. Фаулер в известном разборе моков и заглушек объясняет механику провала: проверка через ожидания вызовов связывает тест с деталями реализации. Рефакторинг без смены поведения ломает десятки проверок, а изменение поведения при тех же вызовах проходит тихо. Тест переворачивается: он защищает код от программиста, а не пользователя от дефекта.
Полезно помнить и словарь Жерара Мезароша: под общим термином test double, по аналогии с дублёром в кино, скрываются заглушки, фейки, шпионы и сами моки. Фейк с рабочей логикой, например база в памяти, безопаснее мока, потому что у него есть собственное поведение, а не только то, что ему приказали.
Метрика покрытия подыгрывает иллюзии, ведь она измеряет факт исполнения строк, а не силу утверждений. Можно покрыть сто процентов кода проверками без единого содержательного сравнения. Мок возвращает то, что ему сказали вернуть, и проверяет то, что ему сказали проверить. Круг замкнулся, ошибка едет в прод.
Живой пример хрупкости выглядит буднично. Проверка фиксирует, что сервис сохраняет заказ ровно один раз:
def test_checkout_saves_order(repo_mock):
service.checkout(Cart(items=[Item("A-1", qty=1)]))
repo_mock.save.assert_called_once()
Переименование метода в persist ломает и код, и проверку синхронно, тут подмена честна. Гораздо хуже соседний сценарий: в save добавили необязательный параметр контекста, поведение не изменилось, а набор покраснел в десятке файлов. Такая проверка наказывает за любое движение внутри класса и незаметно приучает команду бояться рефакторинга. Оправдание у мока одно: честного соседа не существует, как у зависшего тайм-аута сети.
Забавный факт из практики: на одном проекте отчёт показывал стопроцентное покрытие модуля оплаты, а внутри живых проверок была единственная строка assert result. Формально метрика честная, по сути пустышка. Классика другого жанра: сервис оплаты отрефакторили, покрытие держалось выше 95, а в релизе сломался перевод средств. Мок платёжного шлюза не умел отвечать ошибкой 429, настоящий шлюз умел. Интеграционного слоя на живом стенде не было, ловить было некому.
Рабочие пропорции слоёв для типичного бэкенд-сервиса
Универсального процента нет, зато есть стартовая раскладка, которую удобно править под домен:
1. чистая доменная логика, расчёты, парсеры и валидация живут в юнит-тестах, таких проверок сотни и они гоняются секунды;
2. репозитории и запросы проверяются интеграционными тестами на движке СУБД, совпадающем с боевым;
3. границы с соседними сервисами закрываются контрактными проверками и тонкими интеграциями на общем стенде;
4. сквозные сценарии критичных пользовательских путей держатся в узкой вершине, буквально единицы;
5. дымовой набор после деплоя закрывает остаток недоверия за пару минут.
Пропорции пересматривают под домен и после каждого серьёзного инцидента. Если логика спрятана в хранимых процедурах, основание истончается, средний слой толстеет. Для микросервисов с тонкими эндпоинтами часть команд выбирает фигуру сот: короткий юнит-слой, толстая интеграционная середина, почти нет вершины. Это осознанное решение, а не слабость, и его фиксируют в описании тестовой стратегии проекта.
Для ориентира: сервис биллинга на несколько десятков тысяч строк комфортно живёт с раскладкой около тысячи быстрых проверок, полутора сотен интеграционных и восьми сквозных сценариев. Основание исполняется за пятнадцать секунд, середина укладывается в шесть минут на четырёх воркерах, вершине нужен отдельный стенд и полчаса времени. Цифры не норматив, зато порядок величин сразу показывает, где набор разбух.
Настройка прогонов в CI чтобы быстрые проверки не тормозили медленные
Теория скучнее практики, зато практику можно положить в конфиг. Разметка маркерами в pyproject.toml:
[tool.pytest.ini_options]
markers = [
"unit: fast tests with no external systems",
"integration: tests that need postgres or redis",
]
Запуск быстрого набора на каждую ветку и полного вечером:
pytest tests/unit -m unit -q --cov=billing --cov-fail-under=80
docker compose up -d postgres-test redis-test
pytest tests/integration -m integration -n 4
Веточный прогон держится до десяти минут, полный уходит в ночное расписание. Хрупкие проверки помечают отдельным маркером flaky и заводят на них еженедельный план починки, а не блокируют релиз. Порог покрытия вроде 80 процентов на быстром наборе работает как сигнализация против деградации, а не как цель сама по себе. Число растёт, а польза падает? Значит, проверяют исполнение строк, а не поведение, и это первый звонок пересобрать набор.
Долгий полный прогон делят между исполнителями конвейера: набор режут на равные части, и десять минут середины превращаются в две с половиной на четырёх воркерах.
Экономия прячется и в подготовке окружения. Кешированный образ СУБД с готовой схемой стартует за секунды, холодный подъём с накатом миграций съедает минуты. По карте зависимостей конвейер видит затронутые модули и не гоняет середину ради правки документации.
Живой вывод про распределение доверия между уровнями
Пирамида это бюджет доверия. Юнит-уровень покупает скорость и точную локализацию падения, интеграционный платит секундами за проверку стыков, сквозные тесты стоят дороже всех, потому их мало. Покрытие ведёт себя как спидометр: показывает скорость, но не строит дорогу и не отвечает за качество управления.
Проект, где моки заменили реальность на всех этажах, тих и зелёный ровно до первой пятницы. Тот вечер пятницы из начала статьи заканчивается иначе, когда рядом с быстрыми проверками живёт интеграционный слой: ошибка округления всплывает в контейнере с настоящей СУБД через минуту после коммита, а не в понедельник в очереди жалоб. Стыки проверяют там, где они живут, а не там, где их удобно рисовать.