Мутационное тестирование в Java: ломаем код, чтобы сделать его лучше

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Ваши тесты врут, а вы и не знаете

Представьте ситуацию: вы написали модульные тесты, 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: теория на пальцах

Процесс состоит из трёх этапов:

  1. Генерация мутантов. Инструмент (например, PIT) создаёт множество версий вашего кода с мелкими изменениями: меняет операторы, убирает вызовы методов, инвертирует условия.
  2. Запуск тестов. Для каждого мутанта запускаются ваши unit-тесты.
  3. Анализ результатов. Если тест падает — мутант убит. Если проходит — жив. Процент убитых мутантов называется 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,                

Блоги

Рекомендаций по книгам