Новое в журнале. Тестовое покрытие: сколько кода реально достаточно покрыть
Практика и проекты

Тестовое покрытие: сколько кода реально достаточно покрыть

«Реально достаточно» — это около 80% для обычного проекта, а не 100%. Процент покрытия сам по себе ничего не доказывает: важнее, покрыты ли критичные участки кода и проверяют ли тесты логику, а не просто факт выполнения строк. Мутационное тестирование и тестовые двойники показывают, когда высокий процент врёт.

26 октября 202611 мин чтенияПрактикам
Тестовое покрытие: сколько кода реально достаточно покрыть
~80%индустриальный ориентир покрытия
3 видаline, branch, mutation coverage

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

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

Что такое тестовое покрытие и почему это не KPI

Тестовое покрытие (code coverage) — это метрика, которая показывает, какая доля строк, ветвлений или путей выполнения кода была запущена хотя бы один раз во время прогона тестов. Инструмент инструментирует код, запускает тесты и фиксирует, какие участки сработали, а какие остались нетронутыми — результат выражается в процентах.

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

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

Line, branch и mutation coverage — в чём разница

У термина «покрытие» нет одного значения — есть минимум три вида метрики, и они измеряют разные вещи.

Тип покрытияЧто измеряетСлабое место
Line coverageДоля строк кода, выполненных хотя бы разНе видит непройденные ветки внутри одной строки (например, условие с несколькими операторами)
Branch coverageДоля пройденных логических ветвей — true/false для каждого условияПроверяет, что ветка выполнилась, но не то, что её результат правильный
Mutation coverageДоля намеренно испорченных версий кода («мутантов»), которые тесты поймалиТребует отдельного инструмента и заметно больше времени на прогон

Иерархия по строгости такая: mutation coverage строже branch coverage, который строже line coverage. Проект может иметь 95% line coverage и при этом пропускать значительную часть логических ошибок — именно это несоответствие и путает команды, которые ориентируются только на самую простую из трёх метрик.

Почему 90–100% покрытия не спасает от багов

Рассмотрим простую функцию расчёта скидки:

function applyDiscount(float $price, float $percent): float {
    return $price - ($price * $percent / 100);
}

Тест вроде такого даёт функции 100% line coverage:

public function testApplyDiscount(): void {
    $result = applyDiscount(100, 10);
    $this->assertIsFloat($result);
}

Строка выполнилась, тест прошёл, покрытие — 100%. Но тест не проверяет, что результат равен 90, — он проверяет только тип возвращаемого значения. Если в коде поменять знак минус на плюс, тест останется зелёным, а баг уйдёт в продакшен необнаруженным. Это и есть ложное чувство защиты: цифра покрытия высокая, а гарантия корректности — нулевая.

Похожая логика встречается и с условными ветвлениями: тест может пройти обе ветки if/else, не проверив, что каждая из них возвращает ожидаемое значение. Именно поэтому опытные команды смотрят не только на процент, но и на содержимое assert — утверждают ли тесты что-то конкретное о поведении или просто фиксируют факт выполнения строки.

Сколько процентов покрытия реально достаточно

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

Данные Software Engineering Institute (SEI) по проектам с высокой зрелостью процессов показывают похожую картину: прирост надёжности от покрытия выше примерно 85% статистически слабо отличим от шума, а стоимость его достижения растёт почти линейно. На практике это значит, что гнаться за последними 15-20% покрытия в обычном продукте — часто не лучшее вложение времени команды.

Из этого правила есть осмысленное исключение — критичные и safety-critical системы: финтех, медтех, авиационное и промышленное ПО, где цена необнаруженной ошибки несопоставима со стоимостью лишних тестов. Там стремление к максимально возможному покрытию, включая mutation coverage, оправдано и часто требуется регулятором или внутренним стандартом качества.

Конкретная цифра также зависит от возраста кодовой базы. Для нового проекта, который пишется с тестами с первого коммита, 80% и выше достигаются почти без усилий — тесты пишутся параллельно с кодом. Для legacy-системы, в которую тесты заводят постфактум, тот же порог для всей кодовой базы сразу может оказаться нереалистичным на годы вперёд, и там разумнее применять правило «порог для нового кода», которое разобрано ниже в разделе про стратегию внедрения.

Как измерить покрытие кода на практике

Для PHP-стека line и branch coverage снимают расширения Xdebug или PCOV в связке с PHPUnit — PCOV заметно быстрее на больших сьютах и обычно предпочтительнее в CI. Отчёт можно получать в HTML для локального просмотра или в формате Clover/Cobertura для интеграции с системами качества вроде SonarQube.

Для JS/TS-стека аналогичную роль играет Istanbul — движок под капотом Jest и Vitest. Он встроен в большинство тест-раннеров и не требует отдельной настройки: достаточно включить флаг покрытия в конфиге.

Branch coverage стоит включать отдельно: по умолчанию многие конфигурации считают только line coverage, и команда может месяцами не замечать, что часть условных веток вообще не выполняется. Подключение отчёта о покрытии к CI как незаблокирующего шага — разумный первый шаг: видимость без жёсткого порога снижает сопротивление команды лучше, чем сразу вводить требование «не ниже 80%».

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

Мутационное тестирование: как проверить качество самих тестов

Mutation testing отвечает на вопрос, который line и branch coverage не задают: а заметят ли тесты вообще, если в коде появится баг? Инструмент автоматически вносит в код небольшие искажения — «мутации»: меняет < на <=, плюс на минус, убирает условие, сдвигает индекс — и прогоняет тесты заново на каждой мутации.

Если тесты падают — мутант убит, тест сработал как защита. Если тесты остаются зелёными — мутант выжил, и это сигнал, что соответствующий участок кода покрыт формально, но не проверяется по существу. Процент убитых мутантов (mutation score) честнее показывает реальную защищённость кода, чем line coverage, и подробный разбор механики мутационного тестирования есть, например, в материале Хабра о ложном чувстве защиты от покрытия.

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

Абстрактная схема ветвящихся путей выполнения кода

Тестовые двойники и зачем они покрытию

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

  • Dummy — передаётся только для соответствия сигнатуре, в тесте не используется
  • Stub — возвращает заранее заданные ответы, не проверяет, как его вызвали
  • Fake — упрощённая рабочая реализация (например, база данных в памяти вместо настоящей СУБД)
  • Mock — проверяет сам факт и детали вызова: сколько раз, с какими аргументами
  • Spy — оборачивает реальный объект и попутно записывает, как его использовали

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

Какие участки кода покрывать первыми

Когда тестов в проекте мало, в приоритете не «удобный для покрытия» код, а код, где цена ошибки выше всего.

  • Платёжная логика и расчёты сумм — ошибка здесь стоит денег напрямую
  • Аутентификация и авторизация — ошибка открывает доступ не тому пользователю
  • Вычисления с датами и денежными суммами — классический источник edge-case багов: округление, часовые пояса, концы месяца
  • Код с историей багов — модуль, в который регрессии возвращались хотя бы дважды, статистически вернётся туда снова
  • Разделяемые утилиты — функция, которую вызывают из десятков мест, умножает цену одной ошибки на количество вызовов

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

Стратегия внедрения: с чего начать в команде

Если в проекте до сих пор не было дисциплины покрытия, внедрять её стоит постепенно, а не одним решением сверху.

  1. Замерить текущее покрытие baseline. Прогнать coverage-отчёт по всему проекту один раз и зафиксировать число, без попытки сразу его улучшить.
  2. Договориться о пороге для нового кода, а не для всего legacy. Требовать, например, 80% от PR, который меняет существующий файл, а не от всей кодовой базы сразу — иначе команда либо забросит метрику, либо начнёт обходить проверку.
  3. Включить отчёт в CI как видимый, но сначала некритичный шаг. Покрытие должно быть на виду у всех до того, как оно начнёт блокировать мерж.
  4. Сделать покрытие частью code review. Ревьюер смотрит не только на diff покрытия, но и на то, проверяет ли новый тест конкретное поведение, а не просто факт выполнения строки.
  5. Пересматривать порог раз в квартал. По мере роста зрелости кодовой базы порог можно поднимать, а для участков с критичной логикой — добавлять точечный mutation testing.

Эти пять шагов работают именно в такой последовательности: без baseline порог будет взят с потолка, а без ограничения «только новый код» метрика почти всегда упирается в сопротивление команды и объём legacy, который никто не планировал переписывать ради процента.

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

Частые вопросы

Сколько процентов покрытия тестами считается хорошим?

Индустриальный ориентир — около 80% для обычного проекта. Это не жёсткий стандарт, а точка, после которой прирост надёжности на процент покрытия начинает обходиться непропорционально дорого. Для критичных систем (финтех, медтех) оправдано стремиться выше.

Нужно ли стремиться к 100% покрытию?

В большинстве проектов — нет. Последние 15-20% покрытия чаще всего приходятся на редкие edge-case и обработку ошибок, которые дорого тестировать и которые реже ломаются. Исключение — safety-critical системы, где цена необнаруженной ошибки оправдывает такие вложения.

Что лучше — line coverage или branch coverage?

Branch coverage строже и информативнее: он проверяет, что пройдены обе ветки каждого условия, а не просто то, что строка с условием выполнилась. Там, где возможно, стоит включать branch coverage вместо или в дополнение к line coverage — разница в настройке минимальна.

Можно ли доверять высокому проценту покрытия без код-ревью тестов?

Нет. Высокий процент line или даже branch coverage можно получить тестами без единого содержательного assert. Ревью самих тестов — не только production-кода — обязательная часть проверки, иначе метрика легко становится формальностью.

Как покрытие тестами влияет на скорость рефакторинга?

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

Нужно ли мутационное тестирование маленькой команде?

Необязательно на весь проект — это дорого по времени прогона. Но точечный прогон на 2-3 самых критичных модулях (платежи, авторизация) даёт непропорционально много информации о реальном качестве тестов относительно затраченного времени.

С чего начать, если в проекте вообще нет тестов?

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

Код, который не стыдно передать

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

Случайный разбор