Алексей Меленчук
вся записка
узел 01 · Ядро
дата
объём 7 мин чтения

Пирамида тестов: сколько юнит и сколько интеграционных

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

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

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

Вот из этой сцены и растёт пирамида. Не из методички.

Что вообще считается юнит-тестом

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

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

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

Почему пирамида, а не прямоугольник

Форма получается сама, если посчитать по каждому тесту две величины.

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

Точность локализации. Сколько кода попадает под подозрение в момент покраснения. У юнита — функция. У интеграционного — код плюс схема плюс конфигурация подключения. У сквозного — всё перечисленное, сеть, чужой сервис и таймаут, которому сегодня не повезло.

Перемножьте. Дешёвый и точный тест ничего не обязан оправдывать. Дорогой и размытый забирает время конвейера и время человека, а значит обязан ловить то, чего дешёвый не увидит принципиально. Пирамида — картинка этого неравенства, а не разнарядка «семьдесят на двадцать на десять».

Почему покрытие кода в процентах меряет не то

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

Второй изъян глубже. Покрытие — свойство кода, риск — свойство поведения. Строка, считающая НДС, и строка, форматирующая сообщение в лог, весят в проценте одинаково.

Третий изъян начинается там, где процент становится порогом в конвейере. Дальше он измеряет старания дотянуть до порога: появляются тесты на геттеры и на сгенерированный код. Число растёт, проверено столько же — обычная судьба показателей кода.

Фаулер в заметке про покрытие оставляет метрике одно применение: она находит непроверенные места. То есть полезен перечень, а не число. И вопрос к отчёту звучит так: что именно не покрыто и почему меня это устраивает. Тут бывает нормальный ответ — «обёртка над библиотекой, её закрывает интеграционный». На «сколько процентов» нормального ответа нет.

Где ставить тест: признак, доведённый до числа

Правило одно. Тест ставится на самом низком уровне, на котором он способен провалиться от той ошибки, которой вы боитесь.

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

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

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

Сквозных — по числу путей, о поломке которых вы узнаете от клиента, а не из лога. Обычно это оплата и вход. Третий путь находится не всегда, и если он у вас есть, вы его знаете и без списка.

Бюджет конвейера задаёт форму

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

Дальше второй порядок. Медленный набор не остаётся медленным, он становится выключенным: сначала гоняют по ночам, потом смотрят раз в неделю, потом копятся «известные красные». Реальное покрытие выключенного теста — ноль, какой бы процент он ни рисовал.

Поэтому верхний уровень ограничен не вкусом, а окном, за которое конвейер обязан ответить.

Когда пирамида честно переворачивается

Из сказанного напрашивается: интеграционных надо поменьше. Вывод неверный, и ломается на конкретном классе сервисов.

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

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

Зачем вообще писать тесты

Ответ «чтобы ловить ошибки» сбивает с толку: тест ловит ровно ту ошибку, которую вы придумали заранее, а про остальные молчит зелёным цветом.

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

Условие, при котором я неправ

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

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

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