Ваши тесты врут, а вы и не знаете
Представьте ситуацию: вы написали модульные тесты, code coverage (покрытие кода) составляет 95%. Руководитель доволен, CI/CD пайплайн зелёный. Но в пятницу вечером прилетает баг в продакшн. Знакомо?
Проблема в том, что классическое покрытие кода — это иллюзия. Оно показывает, какие строки кода были выполнены, но не отвечает на главный вопрос: действительно ли тесты проверяют логику? Именно здесь на сцену выходит mutation testing — методика, которая безжалостно "ломает" ваш код, чтобы проверить, насколько хороши ваши тесты.
В этой статье мы разберём, что такое мутационное тестирование в Java, как оно работает, и покажем реальные примеры с использованием инструмента PIT (Pitest). Вы узнаете, как превратить ваши тесты из "галочки в отчёте" в реальную защиту от багов.
Что такое mutation testing и почему это важно?
Mutation testing (или мутационное тестирование) — это метод оценки качества тестов, при котором в исходный код вносятся небольшие, заведомо неправильные изменения (так называемые мутации). После этого запускаются ваши тесты. Если хотя бы один тест падает — мутант "убит", тест сработал. Если же тесты проходят — мутант "выжил", и это тревожный звоночек.
Простая метафора: вы проверяете, насколько хорошо ваша сигнализация ловит воров. Вы подкладываете "вора" (мутанта) и смотрите, сработает ли сигнализация (тест). Если вор спокойно уходит — сигнализация никуда не годится.
Почему code coverage — это плохой показатель?
Представьте такой код:
public boolean isAdult(int age) {
if (age >= 18) {
return true;
}
return false;
}
И тест:
@Test
void testIsAdult() {
assertTrue(isAdult(18));
}
Покрытие кода покажет 100% (обе ветки if-else выполнены?). Нет! Выполнилась только ветка true. Но тест зелёный. А теперь представьте, что кто-то случайно изменил условие на age > 21. Тест всё равно пройдёт, потому что 18 > 21 — это false, а assertTrue ожидает true. Баг уйдёт в продакшн.
Мутационное тестирование мгновенно поймает такую ситуацию, создав мутанта, который меняет >= на > или инвертирует условие.
Как работает mutation testing: теория на пальцах
Процесс состоит из трёх этапов:
- Генерация мутантов. Инструмент (например, PIT) создаёт множество версий вашего кода с мелкими изменениями: меняет операторы, убирает вызовы методов, инвертирует условия.
- Запуск тестов. Для каждого мутанта запускаются ваши unit-тесты.
- Анализ результатов. Если тест падает — мутант убит. Если проходит — жив. Процент убитых мутантов называется mutation score (оценка мутаций).
Хороший показатель: 80-90% убитых мутантов. Всё, что ниже — повод переписать тесты.
Практический пример на Java с PIT
Давайте возьмём реальный пример. Допустим, у нас есть сервис для расчёта скидки:
public class DiscountService {
public double calculateDiscount(double amount, boolean isPremium) {
double discount = 0.0;
if (amount > 100) {
discount = 0.1; // 10% скидка
}
if (isPremium) {
discount += 0.05; // дополнительно 5%
}
// Скидка не может быть больше 30%
if (discount > 0.3) {
discount = 0.3;
}
return discount;
}
}
Напишем наивный тест:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class DiscountServiceTest {
private final DiscountService service = new DiscountService();
@Test
void testCalculateDiscount() {
assertEquals(0.0, service.calculateDiscount(50, false));
assertEquals(0.1, service.calculateDiscount(150, false));
assertEquals(0.15, service.calculateDiscount(150, true));
}
}
Покрытие 100%? Да. А теперь запустим mutation testing.
Настройка PIT в Maven
Добавьте плагин в pom.xml:
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<version>1.15.0</version>
<configuration>
<targetClasses>
<param>com.example.*</param>
</targetClasses>
<targetTests>
<param>com.example.*</param>
</targetTests>
</configuration>
</plugin>
Запустите:
mvn test pitest:mutationCoverage
Что мы увидим?
PIT сгенерирует HTML-отчёт. Результат будет плачевным: mutation score около 50%. Вот несколько выживших мутантов:
- Мутант 1: изменение условия
amount > 100наamount > 101. Тест не упадёт, потому что мы тестируем только со значением 150. Если граница сместится, мы не узнаем. - Мутант 2: удаление строки
if (discount > 0.3). Тесты проходят, потому что ни одна из наших проверок не создаёт скидку больше 30%. А если в будущем кто-то добавит новую акцию? - Мутант 3: изменение
discount += 0.05наdiscount -= 0.05. Тест всё ещё проходит? Да, потому что мы не проверяем отрицательные скидки.
Это классический пример того, как code coverage обманывает, а mutation testing вскрывает слабые места.
Как улучшить тесты после мутационного тестирования?
Вот как мы можем "убить" этих мутантов:
@Test
void testBoundaryAmount() {
// Проверяем границу: 100 - нет скидки, 101 - есть
assertEquals(0.0, service.calculateDiscount(100, false));
assertEquals(0.1, service.calculateDiscount(101, false));
}
@Test
void testMaxDiscountLimit() {
// Проверяем, что скидка не превышает 30%
assertEquals(0.3, service.calculateDiscount(10000,