Почему метрика code coverage врёт в CI/CD и ломает релизы
Метрика code coverage выглядит объективной ровно до момента, когда её сопоставляют с историей падений. Команда держит порог покрытия выше 80 %, сборка зелёная, а регрессии в релизе приходят стабильно. Это не парадокс: отчёт о покрытии показывает, какие строки исполнились хотя бы раз, но молчит о том, проверяли ли их на разных данных и с разными состояниями системы.
Первый источник вранья — непокрытые ветви при формальном выполнении строк. Строка с условием засчитывается как пройденная, если код дошёл до неё, а обработка ошибки, пустой ответ внешнего сервиса или отказ соединения остаются нетронутыми. Релиз уходит дальше с уверенным «покрытие выросло», а падает на первом нештатном сценарии — том самом, ради которого эта ветвь и написана.
Второй — тесты ради числа. Когда порог покрытия превращается в отчётный показатель, появляются проверки, которые вызывают функцию и убеждаются в факте вызова, а не в поведении. Формально качество тестов растёт, реально — растёт объём кода, который никто не проверял по существу, и цена поддержки такого набора превышает пользу от него.
Третий — устаревшие исключения. Файл однажды попал в список игнорируемых: сгенерированный код, разовый адаптер, участок под миграцию. Исключение живёт в конфигурации бессрочно, участок меняется, а метрика по нему не считается — в отчёте дырка, о которой никто не помнит.
Четвёртый — отсутствие истории прогонов. Порог проверяется на каждом коммите, но тренда нет: покрытие тихо сползает с 84 до 76 % за десяток изменений, каждое из которых формально проходит допуск. Ниже разбираем, как это закрывается: что считать, где ставить пороги и как ловить регрессии, а не цифру.
Что даёт управление покрытием: измеримые эффекты
Порог по изменениям вместо общего процента. Проверка смотрит только на строки, которые пришли в текущих изменениях: если покрытие новых участков ниже согласованной границы, слияние останавливается до ревью, а не после. Такой порог не блокирует работу над существующим кодом и не требует героических усилий по поднятию общего процента — снижение числа регрессий достигается точечно.
Тренд покрытия как управляемая метрика. Значения хранятся по веткам и релизам, поэтому видно не «сколько сейчас», а куда движется рост покрытия: какой модуль просел после переписывания, где новые тесты не успевают за кодом. Решение о блокировке принимается по динамике за несколько недель, а не по разовому срезу.
Время разбора упавшего прогона. Отчёт приходит с разбивкой по файлам и ссылкой на непокрытые строки прямо в задании на сборку — не нужно выкачивать артефакт и сопоставлять его с логами вручную. Алерт в канал команды содержит ветку, конкретный участок и автора изменений, поэтому разбор занимает минуты.
Предсказуемость слияний. Одни и те же правила действуют для всех веток и для локального прогона, так что результат проверки известен ещё до отправки изменений. Ревьюер обсуждает логику, а не спорит о покрытии, и очередь слияний перестаёт зависеть от того, кто именно запустил проверку и с какими настройками.
Подходы к контролю покрытия: сравнение и ограничения
Проверка покрытия редко держится на одном числе. Общий порог обходится сам собой: новый модуль без тестов размывает средний процент, а падение на десятые доли блокирует релиз не по делу — команда начинает глушить проверку или опускать порог. Поэтому рядом живут несколько правил: жёсткий порог по изменённым строкам, мягкий общий и тренд.
Считать все метрики из одного прогона умеет SonarQube, так что выбор не в том, что измерять, а в том, какое правило делает блокирующую проверку.
| Подход | Когда подходит | Слабые места |
|---|---|---|
| Общий порог покрытия | Ровная база, метрика меняется медленно | Новый файл без тестов разбавляет процент; порог приходится снижать |
| Порог по изменённым строкам | Активная разработка, частые правки | Не видит деградацию в старом коде, который не трогают |
| Трендовый контроль | Крупные репозитории, где порог упёрся в потолок | Нужна накопленная история замеров, реагирует с задержкой |
| Режим наблюдения | Внедрение проверки на существующем коде | Ничего не блокирует: регресс проходит, пока правило не переключили |
Рабочая комбинация — порог по изменённым строкам как блокирующая проверка, тренд как сигнал о медленном сползании, общий порог как фон. Правила исключений оформляют явно и узко: сгенерированный код, миграции, конфигурация. Иначе исключения разрастаются и проверка теряет смысл.
Внедрение: от аудита конвейера до алертов по покрытию
Внедрение покрытия — не установка инструмента, а перестройка конвейера: сборка, тесты, отчёты и проверки на слияние начинают работать как одна система. Порядок работ ниже устроен так, что каждый этап опирается на данные предыдущего — иначе пороги превращаются в формальность, которую команда отключает при первом неудобном срабатывании.
- Аудит конвейера. Разбираем существующие шаги сборки и тестов, смотрим, какие отчёты формируются и где данные о покрытии теряются между этапами. Результат — карта: обязательные проверки, дублирующиеся прогоны, участки без публикации отчёта.
- Сбор метрик покрытия. Сводим все прогоны к единой схеме: отчёт в общем формате, привязка к конкретному изменению и ветке, загрузка в хранилище истории. Покрытие перестаёт быть выводом в логе и становится данными по каждому модулю.
- История прогонов. Накопленная база даёт сравнение: видно, где покрытие действительно падает, а где колеблется из-за нестабильного теста. По тренду выбираем модули с наибольшим приростом от точечной доработки тестов.
- Пороги и оповещения. Два уровня — общий по кодовой базе и по новому коду в изменении, для критичных модулей жёстче. Оповещение о падении уходит в рабочий канал команды с перечнем файлов и автором изменения, чтобы разбор шёл без поиска причины.
- Проверка на реальных слияниях. Прогоняем на настоящих изменениях, ловим ложные срабатывания, задаём исключения для сгенерированного кода, миграций и самих тестов. Только после этого проверка становится блокирующей.
- Регламент проверок. Передаём документ: как менять порог, кто согласует исключение, что делает дежурный при оповещении. Плюс разбор с командой — чтобы правило жило, а не отключалось.
После передачи регламента система обслуживает себя сама: пороги меняются по процедуре, история копится, падение покрытия видно в день изменения, а не перед релизом.
Что передаём на руки: правила, конфигурации, регламенты
Передача на руки — это момент, когда пороги и исключения команда заказчика меняет сама, не разбирая заново, как устроен сбор. Поэтому кроме работающего конвейера отдаём набор документов, по которым решение о покрытии можно принять без наших инженеров.
- Файл правил покрытия — где объявлены охваты, формат отчёта и точки слияния данных из модульных и интеграционных прогонов.
- Политика исключений — перечень путей и типов кода, выведенных из подсчёта, с обязательным обоснованием и датой пересмотра у каждой записи.
- Шаблон отчёта — срезы по модулям, сравнение с предыдущим релизом и тренд за последние прогоны, а не единственная цифра.
- Схема оповещений — кому и куда уходит сигнал при просадке, с разделением на предупреждение и блокировку слияния.
- Инструкция дежурному — порядок разбора падения: проверить новые исключения, упавшие тесты, ветку с незакрытыми участками.
- Регламент пересмотра порогов — кто и по какому событию поднимает планку, чтобы она не осталась на уровне первого запуска.
- Карта владения — какая команда отвечает за покрытие какого модуля, чтобы вопрос по исключению шёл по адресу, а не к нам.
Проверяем передачу просто: на тестовой ветке меняем порог и добавляем исключение по инструкции, не привлекая наших инженеров. Если получилось с первого раза — документы рабочие, а не декоративные.
Кейс: пороги и тренды убрали регрессии из релизов
К нам пришла продуктовая команда: десяток сервисов в одном репозитории, релизы дважды в неделю, общее покрытие около 70% и полное доверие к этой цифре. Проблема была в том, что средний показатель не отражал риск — правки в платёжном и биллинговом модулях уходили в прод без тестов на тех строках, которые менялись. За квартал команда ловила по три-четыре регрессии на релиз, а разбор с ручной сборкой прежней версии занимал от шести до десяти часов и требовал двух инженеров.
Мы перенесли акцент с общего процента на покрытие изменённых строк: в каждом запросе на слияние отдельно считалось, какая доля добавленных и изменённых строк закрыта тестами. Пороговое правило в SonarQube связали с обязательным статусом проверки — для платёжного и биллингового модулей сборка падает, если изменённая строка не покрыта, для остальных веток порог мягче. Рядом построили недельный тренд и алерты на просадку, чтобы качество было видно до релиза, а не после инцидента.
Метрики до и после
За три месяца покрытие изменённых строк выросло с 46% до 84%, при этом общий процент почти не двигался — он и не был целью. Регрессий на релиз стало одна на пять-шесть выпусков вместо трёх-четырёх, время восстановления сократилось с шести-десяти часов до полутора: откат запускается одной командой по метке предыдущего релиза. Сборка подорожала на две-три минуты — размен, который команда приняла сознательно.
Чем отличается code coverage по строкам и по ветвям?
Покрытие строк отвечает на вопрос «дойдёт ли исполнение до этой строки», покрытие ветвей — «проверены ли все исходы каждого решения». Строковое покрытие закрывает строку, если выполнилась хотя бы одна её инструкция. Покрытие ветвей требует пройти обе стороны условия — истину и ложь — включая тернарные операторы, ветки switch и короткое замыкание в логических выражениях.
Разрыв между метриками и есть источник ложного покрытия. Строка с if (a && b) становится «зелёной», как только a окажется ложным: правая часть не выполнится ни разу, а отчёт покажет 100%. Так же ведут себя ранние возвраты и обработчики исключений — строки закрыты, а сценарий ошибки не проверен.
| Метрика | Что показывает | Где врёт | Роль в гейте |
|---|---|---|---|
| Строки | долю исполненных строк | короткое замыкание, ранние возвраты | тренд по проекту |
| Ветви | все исходы решений | сложные булевы выражения | порог на изменённом коде |
| Условия | каждый операнд отдельно | редко поддерживается целиком | точечно, критичные модули |
| Функции | сам факт вызова | качества проверок не видно | сигнал «модуль забыт» |
Основной метрикой берём покрытие ветвей: инструменты вроде JaCoCo считают её из того же прогона, что и строки, без дополнительных тестов. Строковое покрытие оставляем как индикатор тренда и неиспользуемого кода. Порог по ветвям ставим только на изменённом коде — иначе легаси-модули блокируют любой релиз, а условия и функции держим вспомогательными метриками.
Частые сомнения инженеров перед настройкой покрытия
Сомнения перед вводом порогов обычно технические, а не организационные: инженеры уже видели метрику, которая мешала больше, чем помогала. Ниже — ситуации, которые всплывают почти на каждом внедрении, и то, как они решаются конфигурацией, а не уговорами.
Прогон станет вдвое дольше — сбор покрытия тормозит тесты?
Инструментирование каждого теста действительно дорого. Сбор метрик выносят на выделенную конфигурацию: один прогон с инструментами отдаёт отчёт, остальные задачи в матрице идут без них. Кэш зависимостей и параллельные исполнители возвращают время сборки к прежнему.
Покрытие рисуется пустыми тестами — зачем тогда порог?
По строкам — рисуется, поэтому порог считают по изменённым строкам в запросе на слияние, а не по всему репозиторию. Дополнительно фиксируют долю тестов с проверками утверждений: тест без утверждения строку не закрывает. Такая пара метрик накручивается в разы тяжелее.
У нас монолит и старый код — репозиторий не дотянет до общего порога.
Общий порог и не нужен. Базовый уровень фиксирует текущее состояние, а правило «не хуже, чем было» применяется к изменённым файлам. Отчёты сводят в одно хранилище с привязкой к коммиту, тренд строится по веткам, алерты срабатывают на падение относительно базовой ветки. При распиле монолита планку поднимают по модулям, а исключения для унаследованного кода задают по путям с датой пересмотра.
Обсудим задачу: как настроить пороги и оповещения
Настроить пороги и оповещения «по умолчанию» нельзя: один и тот же процент покрытия значит разное для модуля расчётов и для кода, который просто проксирует внешний вызов. Поэтому старт работ начинаем с разбора того, что уже собирается: какие отчёты формируются, по каким веткам, где проверка уже стоит блокирующей, а где её просто игнорируют. Дальше фиксируем, по каким модулям отказ дороже всего — по ним порог будет жёстче, чем по остальному коду.
Чтобы вернуться с оценкой и планом внедрения, а не с общими соображениями, нам обычно достаточно:
- доступ к конвейеру и права на изменение конфигурации сборки — смотрим реальные шаги, а не пересказ;
- отчёты о покрытии за последние сборки и по основной ветке — из них берём базу для порога, который не опускается ниже достигнутого уровня;
- список критичных модулей и сервисов — по ним ставим жёсткий порог на весь код, а не только на изменённый;
- перечень веток и условий слияния — определяем, где проверка становится обязательной;
- ожидания по режиму проверок: блокировать слияние или сперва только предупреждать и собирать статистику;
- контакт дежурного по сборкам — чтобы разбирать ложные срабатывания сразу, а не копить их в игноре;
- точки, где решение за вами: с какого уровня начинаем и как быстро поднимаем планку по репозиториям.
Опишите задачу — вернёмся с оценкой и планом работ.







