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

Порог покрытия в 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 багов: округление, часовые пояса, концы месяца
- Код с историей багов — модуль, в который регрессии возвращались хотя бы дважды, статистически вернётся туда снова
- Разделяемые утилиты — функция, которую вызывают из десятков мест, умножает цену одной ошибки на количество вызовов
Код, который легко тестировать, но который редко меняется и мало на что влияет, — честный, но низкоприоритетный кандидат. Покрывать его стоит, но не в первую очередь, если ресурсы команды ограничены.
Стратегия внедрения: с чего начать в команде
Если в проекте до сих пор не было дисциплины покрытия, внедрять её стоит постепенно, а не одним решением сверху.
- Замерить текущее покрытие baseline. Прогнать coverage-отчёт по всему проекту один раз и зафиксировать число, без попытки сразу его улучшить.
- Договориться о пороге для нового кода, а не для всего legacy. Требовать, например, 80% от PR, который меняет существующий файл, а не от всей кодовой базы сразу — иначе команда либо забросит метрику, либо начнёт обходить проверку.
- Включить отчёт в CI как видимый, но сначала некритичный шаг. Покрытие должно быть на виду у всех до того, как оно начнёт блокировать мерж.
- Сделать покрытие частью code review. Ревьюер смотрит не только на diff покрытия, но и на то, проверяет ли новый тест конкретное поведение, а не просто факт выполнения строки.
- Пересматривать порог раз в квартал. По мере роста зрелости кодовой базы порог можно поднимать, а для участков с критичной логикой — добавлять точечный 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% чаще всего заканчивается тем, что метрику обходят. Разумнее требовать покрытие только для нового и изменяемого кода.



