Привет, Хабр! Меня зовут Андрей Бирюков. Я — независимый эксперт в области ИТ и ИБ, преподаю в учебных центрах и пишу статьи и книги. Проблема, о которой мы будем говорить сегодня знакома многим тестировщикам.
Jenkins или GitLab CI рапортует: «Покрытие кода тестами — 94%». Зелёная галочка, начальство довольно, команда идёт пить кофе. А на проде через две недели падает критическая фича, потому что кто-то поменял знак сравнения с > на >=, и все тесты… всё равно прошли зелёными.
Добро пожаловать в самую дорогую иллюзию в разработке ПО: уверенность, основанную на покрытии строк. Покрытие в 90% — это как бронежилет из фольги. Он выглядит убедительно на расстоянии, но первая же пуля покажет его настоящую природу. В этой статье мы разберём три конкретные метрики покрытия, их слепые зоны и инструмент, который безжалостно покажет дыры в ваших тестах — мутационное тестирование.

Три типа покрытия: почему строки — самая бесполезная метрика
Разработчики обычно знают только один вид покрытия — сколько строк кода выполнилось во время тестов. Это всё равно, что оценивать здоровье человека по тому, как часто он моргает. Давайте на реальном куске кода увидим разницу между тремя типами покрытия.
public class PaymentProcessor {
public String processPayment(double amount, String currency, boolean isCorporate) {
String result = "pending";
if (amount <= 0) {
result = "invalid_amount";
logError("Negative or zero amount: " + amount);
return result;
}
if (currency == null || currency.isEmpty()) {
result = "invalid_currency";
return result;
}
if (isCorporate) {
// Применяем корпоративный дисконт, но только для валюты USD
if ("USD".equals(currency)) {
amount = amount * 0.95;
result = "corporate_discount_applied";
} else {
result = "corporate_not_supported_currency";
return result;
}
}
// Основная логика списания (опустим для примера)
if (amount > 10000) {
result = "requires_approval";
} else {
result = "approved";
}
return result;
}
private void logError(String message) {
// логирование
}
}Line Coverage (покрытие строк)
Этот подход, пожалуй является самым распространенным и самым наивным. Инструмент типа JaCoCo или coverage.py просто отмечает, какие строки кода были выполнены хотя бы один раз.

Напишем тест, который даст нам высокое покрытие:
@Test
void testHappyPath() {
PaymentProcessor processor = new PaymentProcessor();
String result = processor.processPayment(100.0, "USD", false);
assertEquals("approved", result);
}Что же в итоге здесь выполняется? Строки: объявление result, проверка amount (мимо, amount > 0), проверка currency (прошли), пропустили блок isCorporate, попали в блок else для amount > 10000 (нет, 100 <= 10000). Выполнились строки 3, 5, 9, 11, 28, 30, 32. Грубо говоря, 7 из 32 строк.
Но если мы напишем тест, который проходит все ветки по верхам, покрытие может подняться до 80-90%, при этом критические ошибки останутся незамеченными. Почему? Потому что line coverage не различает, как именно выполнена строка. Строка с условием if (a && b) считается выполненной, даже если в неё зашли только по одному из двух путей.
Branch Coverage (покрытие ветвей)
Это уже более интересный способ. Branch coverage считает не строки, а логические ветвления — каждый if, каждый switch, каждый тернарный оператор. Для каждого условия инструмент проверяет, были ли выполнены оба исхода (true и false).

В нашем примере с PaymentProcessor есть несколько ветвлений:
1. amount <= 0 — два исхода
2. currency == null || currency.isEmpty() — два исхода (но здесь два условия в одном if — некоторые инструменты считают их отдельно)
3. isCorporate — два исхода
4. Внутри isCorporate: "USD".equals(currency) — два исхода
5. amount > 10000 — два исхода
Чтобы достичь 100% покрытия, вам нужно минимум 5 тестов, покрывающих разные комбинации. Но и это не даст полной защиты. Почему? Потому что branch coverage не заботится о последовательности операций и о том, как значения комбинируются между ветками.
Mutation Coverage (мутационное покрытие)
А вот эти мутанты позволяют проверить ваш код по-настоящему. Вы не просто измеряете, что выполнилось, вы активно проверяете, способны ли ваши тесты обнаружить изменения в логике.
Идея проста и гениальна: мутационный тест-раннер берёт ваш код, создаёт тысячи его копий (мутантов), в каждой копии вносит одно маленькое изменение (мутацию), а затем прогоняет ваши тесты. Если тесты падают — мутант убит. Если тесты проходят — мутант выжил, и у вас дыра в тестовом наборе.

Мутационное тестирование на практике: PITest
Для реализаци мутационного тестирования в разных языках есть свои решения. В Java-мире стандарт — PITest, для Python — mutmut или Cosmic Ray, для JavaScript — Stryker. Механика везде будет похожей.
Установим PITest для Maven:
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<version>1.15.0</version>
<configuration>
<targetClasses>
<param>com.example.payment.*</param>
</targetClasses>
<targetTests>
<param>com.example.payment.*</param>
</targetTests>
<mutators>
<mutator>ALL</mutator>
</mutators>
<outputFormats>
<format>HTML</format>
</outputFormats>
</configuration>
</plugin>Для запуска выполним: mvn org.pitest:pitest-maven:mutationCoverage
Типы мутаций, которые разоблачают ваши тесты
PITest применяет десятки различных типов мутаций. Вот самые показательные:
Мутации условий (Conditional Boundary Mutator)
Исходный код: if (amount > 10000)
Мутации:
- if (amount >= 10000) — сдвиг границы
- if (amount < 10000) — инверсия
- if (amount > 9999) — арифметический сдвиг
Пример: у вас есть тест, который проверяет порог в 10000. Он передаёт amount = 10001 и ждёт "requires_approval". Тест проходит. Но что, если мутант изменил условие на >=? Ваш тест с 10001 всё равно пройдёт, потому что 10001 >= 10000 тоже true. Вы не поймали мутанта. Ошибка: ваш тест не проверяет поведение ровно на границе.
Для исправления добавьте тест с amount = 10000. До мутации должно быть "approved" (если <= 10000), после мутации (>= 10000) должно стать "requires_approval". Тест, который проверяет и то, и другое, убьёт мутанта.
Мутации возвращаемых значений (Return Values Mutator)
Исходный код: return result;
Мутация: return null; или return ""; (для строк) или return 0; (для чисел)
Ужасающая мутация. Если у вас нет теста, который явно проверяет не-null и не-пустоту возвращаемого значения в разных сценариях, мутант выживает. Ваш тест просто сравнивает с ожидаемой строкой, но если обе строки пустые — тест пройдёт.
Мутации инкрементов (Increments Mutator)
Исходный код: counter++ или counter += 1
Мутация: counter-- или counter -= 1
Классический случай. Цикл, который должен выполниться 10 раз, выполняется 8 или 12 раз. Ваш тест проверяет финальное состояние, но не количество итераций? Мутант выживает.
Мутации математических операторов (Math Mutator)
Исходный код: result = a + b;
Мутации:
- result = a - b;
- result = a * b;
- result = a / b;
- result = a % b;
Это самые коварные мутации, потому что тесты часто проверяют только "не ноль" или "положительное число". Мутант с заменой + на - может дать тот же знак результата (например, 5+3 = 8, 5-3 = 2 — оба положительные, оба не ноль). Тест, который проверяет точное значение, убьёт мутанта. Тест, который проверяет result > 0 — нет.
Мутации констант (Constant Mutator)
Исходный код: if (statusCode == 200)
Мутации:
- if (statusCode == 201)
- if (statusCode == 199)
- if (statusCode == 0)
Эти мутации особенно опасны для HTTP-клиентов и парсеров. Если у вас есть тест с моком, который возвращает 200, а не реальным вызовом, вы никогда не узнаете, что ваш код сломается на реальном 201.
Полный цикл убийства мутанта
Давайте проследим, как выглядит настоящий тест, способный убить мутанта в нашем PaymentProcessor.
Возьмём конкретную мутацию: PITest изменяет amount <= 0 на amount < 0. Теперь отрицательный ноль (0.0) не считается невалидным. Ваш тест:
@Test
void testNegativeAmountShouldBeInvalid() {
PaymentProcessor processor = new PaymentProcessor();
String result = processor.processPayment(-10.0, "USD", false);
assertEquals("invalid_amount", result);
}Этот тест убьёт мутанта? Нет. Потому что -10.0 меньше 0, и при исходном условии (amount <= 0) и при мутированном (amount < 0) условие срабатывает. Результат одинаков. Мутант выжил.
Что нужно? Тест с amount = 0.0:
@Test
void testZeroAmountShouldBeInvalid() {
PaymentProcessor processor = new PaymentProcessor();
String result = processor.processPayment(0.0, "USD", false);
assertEquals("invalid_amount", result);
}
@Test
void testNegativeAmountShouldBeInvalid() {
PaymentProcessor processor = new PaymentProcessor();
String result = processor.processPayment(-0.01, "USD", false);
assertEquals("invalid_amount", result);
}Теперь:
Исходный код (<=0): оба теста проходят, оба возвращают "invalid_amount".
Мутированный код (<0): первый тест с 0.0 не заходит в условие (0.0 не < 0) и идёт дальше, возвращая что-то другое. Второй тест с -0.01 проходит. Первый тест падает, соответственно, мутант убит.
Как читать отчёт мутационного тестирования
После прогона PITest вы получите HTML-отчёт с цветовой индикацией:
Зелёные строки — мутанты убиты. Ваши тесты хороши здесь.
Красные строки — мутанты выжили. Здесь ваши тесты не видят разницы между правильной логикой и сломанной.
Жёлтые строки — мутанты не были созданы (например, код никогда не выполняется в принципе — мёртвый код).

Процент мутационного покрытия считается просто:
Mutation Score = (Убитые мутанты) / (Всего мутантов - Нежизнеспособные) 100%
Практический порог, к которому стоит стремиться в критических модулях: 80-85% mutation coverage. Достичь 100% почти невозможно (и часто бессмысленно) из-за мутаций в логировании, getter/setter и инфраструктурном коде.
Но есть важный нюанс: мутационное тестирование медленное. PITest для модуля в 5000 строк может работать 5-10 минут. Поэтому его обычно запускают:
Ночью на CI для критических модулей
Принудительно перед релизом
Только для кода, который изменился в PR (инкрементальный режим)

Давайте рассмотрим реальный пример со следующими результатами: 90% line coverage, 60% branch coverage, 30% mutation coverage.
Вот код из production-системы, который проходил все проверки по line coverage, но упал в боевом окружении:
def calculate_discount(customer_tier, purchase_amount, is_first_purchase):
discount = 0.0
if customer_tier == "gold":
discount = 0.15
elif customer_tier == "silver":
discount = 0.10
elif customer_tier == "bronze":
discount = 0.05
if is_first_purchase:
discount = discount + 0.10
if purchase_amount > 500:
discount = discount + 0.05
# Логическая ошибка: не проверяется, что скидка не больше 30%
return discountLine coverage: 100% (все строки выполнились хотя бы в одном тесте).
Branch coverage: 85% (проверили gold/silver/bronze, с флагом и без, с суммой меньше и больше 500).
Мутационный тест находит проблему:
Мутация 1: discount = discount + 0.10 → discount = discount - 0.10 (инверсия знака). Выжил. Почему? Потому что ни один тест не проверял, что скидка при первом заказе увеличивается, а не уменьшается. Все тесты просто проверяли, что discount стал больше нуля.
Мутация 2: if purchase_amount > 500 → if purchase_amount >= 500 (сдвиг границы). Выжил. Никто не тестировал сумму ровно 500.
Мутация 3: Отсутствие ограничения 30% — не мутация, а архитектурная проблема. Но если бы ограничение было, мутация, удаляющая его, тоже выжила бы.
Итог: код упал на проде, когда gold-клиент с первым заказом на 1000 получил скидку 0.15 + 0.10 + 0.05 = 30%, а должна была быть 25% по бизнес-правилу.
Стратегия внедрения: с чего начать в понедельник
Вам не нужно срочно бежать и прогонять PITest на всём монолите. Вы завалите CI и демотивируете команду. Здесь нужно действовать постепенно:
Шаг 1. Выберите один модуль для пилота.
Кандидат: модуль с бизнес-логикой (не CRUD, не DTO). Где есть условия, расчёты, конвертации. Где баг стоит дорого.
Шаг 2. Запустите мутационный тест и посмотрите на выживших мутантов.**
Скорее всего, их будет много. Не пугайтесь. Сортируйте по типам:
Мутации в
toString(),hashCode(),equals()— часто можно игнорировать или поставить в исключения.Мутации в граничных условиях — критичны, добавляйте тесты.
Мутации в арифметике — критичны, добавляйте тесты с конкретными числами.
Мутации в константах статусов (200, 404, 500) — важны для кода, работающего с внешними API.
Шаг 3. Добавьте тесты, которые убивают хотя бы самых опасных мутантов.
Не стремитесь к 100% mutation coverage сразу. Добавьте 5-10 тестов, которые убивают мутантов в самой важной ветке бизнес-логики. Увидите, как упадёт количество false positive.
Шаг 4. Добавьте мутационный тест в ночной CI для этого модуля.
# GitLab CI example
mutation-test:
stage: verify
script:
- mvn org.pitest:pitest-maven:mutationCoverage
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: target/site/pitest/index.html
only:
- schedules
- mainШаг 5. Введите правило для новых PR.
Любой новый код, изменяющий условия (if/switch) или арифметику, должен сопровождаться тестами, которые убивают соответствующих мутантов. Проверять можно либо локальным прогоном PITest на изменённых файлах, либо инкрементальным плагином.
Итог
Мораль сегодняшний статьи можно коротко выразить следующей фразой: покройте логику, а не строки. Метрика code coverage по строкам — это индикатор того, что вы вообще запускаете тесты. Не больше. Вы можете иметь 100% line coverage и 0% mutation coverage, если ваши тесты проверяют только то, что код не падает с исключением, но не проверяют, что он делает правильные вещи.
Branch coverage уже лучше — он заставляет вас думать о разных исходах условий.
Mutation coverage — это единственная метрика, которая отвечает на правильный вопрос: «А заметят ли мои тесты, если кто-то (или я сам) случайно сломает логику?».
Помните: зелёная полоса в CI означает только то, что тесты выполнились. Она не означает, что ваш код корректен. И уж точно не означает, что вы можете спать спокойно.
Мутационное тестирование — это как прививка от самоуверенности. Она немного болит, она требует времени, но она спасает от гораздо более серьёзной болезни под названием «прод в огне, а тесты зелёные».
Больше о тестировании читайте в материалах:
Почему классический подход к QA больше не работает (и виновата ли в этом эпоха ИИ)
Claude против краевых случаев: как LLM-агент нашёл баги в NumPy и других Python-библиотеках

Качество тестов определяется не только покрытием, но и тем, какие сценарии они действительно способны проверить. Продолжить разбираться в автоматизации тестирования, применении ИИ и метриках качества можно на бесплатных уроках OTUS:
16 июня, 20:00. «ИИ в автотестах: помощник или угроза?» Записаться
18 июня, 20:00. «Тесты, которые чинят себя сами: практика ИИ в UI-тестировании». Записаться
2 июля, 20:00. «Как читать баги: метрики для руководителей команд тестирования (QA Lead)». Записаться
Полный список бесплатных уроков июня смотрите в дайджесте.




















