Пирамида тестов: сколько юнит и сколько интеграционных
Что такое пирамида тестов, чем юнит-тест отличается от интеграционного, почему покрытие кода в процентах меряет не то и по какому признаку решать, где ставить тест.
Коротко. Форма пирамиды — следствие двух величин: во сколько обходится прогон и насколько точно падение указывает на причину. Число интеграционных считается не в процентах, а по числу границ, которые вы подменяете заглушками.
Конвейер красный. Упал тест с именем вроде checkout_e2e, и по отчёту
непонятно ничего: то ли сломали расчёт скидки, то ли переименовали
колонку, то ли платёжная заглушка сегодня отвечала дольше обычного.
Прежде чем чинить, придётся выяснить, есть ли что чинить.
Вот из этой сцены и растёт пирамида. Не из методички.
Что вообще считается юнит-тестом
Единого определения нет, и это не придирка. Мартин Фаулер разбирает разнобой: одни зовут юнитом класс, другие — отдельную функцию, третьи — горстку тесно связанных классов, и расходятся ещё в том, подменять ли соседей заглушками.
Рабочая формулировка выходит не из размера, а из решения: юнит-тест это тест, где вы заменили всё, что ходит наружу. База, HTTP, файловая система, часы, очередь — подменены. Остались код и арифметика.
Он быстрый, потому что медленному взяться неоткуда, и точный: покраснел — под подозрением ровно проверяемая функция. Ценой слепого пятна. Заглушка репозитория, послушно отдающая положенное в неё, ничего не знает про колонку, переименованную вчера в миграции.
Почему пирамида, а не прямоугольник
Форма получается сама, если посчитать по каждому тесту две величины.
Стоимость прогона. Не секунды сами по себе, а секунды, умноженные на число запусков. Тест на каждое сохранение файла обязан укладываться в миллисекунды, тест на слияние ветки может позволить себе секунды, тест перед релизом — минуты.
Точность локализации. Сколько кода попадает под подозрение в момент покраснения. У юнита — функция. У интеграционного — код плюс схема плюс конфигурация подключения. У сквозного — всё перечисленное, сеть, чужой сервис и таймаут, которому сегодня не повезло.
Перемножьте. Дешёвый и точный тест ничего не обязан оправдывать. Дорогой и размытый забирает время конвейера и время человека, а значит обязан ловить то, чего дешёвый не увидит принципиально. Пирамида — картинка этого неравенства, а не разнарядка «семьдесят на двадцать на десять».
Почему покрытие кода в процентах меряет не то
Первый изъян лежит на поверхности: процент отвечает на вопрос «какие строки посетил прогон», а не «какое поведение проверено». Разница не теоретическая: удалите из набора все проверки, оставив вызовы, — процент не шелохнётся. Останется набор, доказывающий, что код не падает с исключением.
Второй изъян глубже. Покрытие — свойство кода, риск — свойство поведения. Строка, считающая НДС, и строка, форматирующая сообщение в лог, весят в проценте одинаково.
Третий изъян начинается там, где процент становится порогом в конвейере. Дальше он измеряет старания дотянуть до порога: появляются тесты на геттеры и на сгенерированный код. Число растёт, проверено столько же — обычная судьба показателей кода.
Фаулер в заметке про покрытие оставляет метрике одно применение: она находит непроверенные места. То есть полезен перечень, а не число. И вопрос к отчёту звучит так: что именно не покрыто и почему меня это устраивает. Тут бывает нормальный ответ — «обёртка над библиотекой, её закрывает интеграционный». На «сколько процентов» нормального ответа нет.
Где ставить тест: признак, доведённый до числа
Правило одно. Тест ставится на самом низком уровне, на котором он способен провалиться от той ошибки, которой вы боитесь.
Страх задаёт уровень, а не наоборот. Боитесь, что скидка посчитается неверно при трёх купонах сразу, — это юнит, и сквозной его дешевле не проверит. Боитесь, что поле в запросе названо не так, как колонка в базе, — юнит с подменённым репозиторием не провалится от этого никогда.
Дальше следствие, дающее число вместо процента. Каждая заглушка — утверждение о чужом поведении. Вы объявили: база вернёт такую строку, шлюз ответит таким телом. Пока утверждение не проверено на настоящей стороне, это вера, и юнит-тесты вокруг неё лишь пересказывают её другими словами.
Отсюда счёт интеграционных: по одному на каждую подменяемую границу. Репозиторий — тест на настоящей базе, клиент платёжного шлюза — на песочнице или на записанных ответах. Это перечень, а не доля: выпишите заглушки из юнит-тестов и поставьте против каждой имя теста, который проверяет границу всерьёз. Пустые строки в этом списке и есть настоящая дыра, а в проценте покрытия её не видно.
Сквозных — по числу путей, о поломке которых вы узнаете от клиента, а не из лога. Обычно это оплата и вход. Третий путь находится не всегда, и если он у вас есть, вы его знаете и без списка.
Бюджет конвейера задаёт форму
У конвейера есть бюджет, и он человеческий: ответ должен прийти, пока изменение держится в голове. Позже — разработчик возвращается в задачу, из которой уже вышел.
Дальше второй порядок. Медленный набор не остаётся медленным, он становится выключенным: сначала гоняют по ночам, потом смотрят раз в неделю, потом копятся «известные красные». Реальное покрытие выключенного теста — ноль, какой бы процент он ни рисовал.
Поэтому верхний уровень ограничен не вкусом, а окном, за которое конвейер обязан ответить.
Когда пирамида честно переворачивается
Из сказанного напрашивается: интеграционных надо поменьше. Вывод неверный, и ломается на конкретном классе сервисов.
Возьмите переходник: принял HTTP, сходил в базу, разложил ответ в JSON. Изолировать здесь нечего, и юнит-тест на такой обработчик проверяет лишь, что заглушка настроена так, как её настроили. Вся содержательная работа идёт на границах, которые юнит подменяет.
Пирамида описывает код, у которого есть ядро предметной области. Нет ядра — нет и основания. Кент Доддс с его «трофеем» спорит не с этим: его довод — чем больше тест похож на то, как софтом пользуются, тем больше доверия он даёт, — и адресован интерфейсным приложениям, где в основании стоит ещё и статический анализ, которого в пирамиде нет вовсе. Но выстреливает этот довод ровно там, где ядра нет и похожесть на живой сценарий остаётся единственным источником доверия. Проверяется это взглядом на то, где у вас живёт логика.
Зачем вообще писать тесты
Ответ «чтобы ловить ошибки» сбивает с толку: тест ловит ровно ту ошибку, которую вы придумали заранее, а про остальные молчит зелёным цветом.
Покупается другое — право менять код, не перечитывая его целиком. Платится один раз, отдача пропорциональна числу будущих изменений. Отсюда непопулярное: разовый скрипт переноса данных можно не покрывать вовсе, а тривиальный модуль, который правят каждую неделю, — стоит.
Условие, при котором я неправ
Всё изложенное предполагает, что тесты у вас есть и спор идёт о форме. Если их нет, спор о пропорциях — удобный способ не начинать.
Тогда мой же совет нужно нарушить и начать со сквозного теста на денежный путь — по моей арифметике худшего из возможных: самого дорогого в прогоне и самого размытого в локализации. Но при нуле тестов вопрос стоит не «насколько точно локализуется отказ», а «узнаем ли мы о нём вообще». Точность имеет смысл там, где есть сигнал.
Пирамида — не план, а результат замера. Если вы не знаете, сколько идёт ваш конвейер и сколько времени уходит от красного теста до понимания причины, форма у вас всё равно уже есть. Просто её никто не выбирал.