Покрытие кода является довольно стандартной метрикой, и многие из нас используют её для оценки качества модульного тестирования. В конце концов, если определённые строки кода ни разу не выполняются во время тестов, вполне вероятно, что сценарии, в которых они задействованы, протестированы не полностью.

Однако может ли сам факт того, что строка кода выполняется в тесте, гарантировать, что соответствующая логика проверена достаточно тщательно? На этом этапе возникает необходимость не в количественной, а в качественной оценке тестов.

Идея мутационного тестирования заключается в том, чтобы случайным образом изменять тестируемый код, создавая различные варианты поведения, которые разработчик не предусматривал,(так называемый мутанты). Затем модульные тесты запускаются для разных мутаций: если тест падает, мутант обнаружен (или, в терминах мутационного тестирования «убит»), а значит, тест достаточно сильный. Если же тест проходит (мутант «выжил»), значит, он охватывает не все возможные варианты поведения программы и потенциально уязвим.

Pitest

Концепция мутационного тестирования стала популярной уже довольно много лет назад, и сегодня она реализована в нескольких фреймворках. Один из наиболее успешных и популярных — Pitest.

Сравнение систем мутационного тестирования для Java (отсюда: ссылка)
Сравнение систем мутационного тестирования для Java (отсюда: ссылка)

Чтобы всё заработало, достаточно иметь код, несколько тестов для него и добавить зависимости Pitest в конфигурацию Maven или Gradle.

Небольшое практическое отступление

У команды Amplicode есть готовый skill для мутационного тестирования. Он берёт на себя рутину вокруг Pitest: объясняет Agent‑у, как настроить проект, запустить мутационное тестирование и прочитать итоговый отчёт.

Во время выполнения плагин:

  1. Определяет строки кода, покрытые тестами (мутировать непокрытый код нет смысла).

  2. Копирует соответствующие файлы классов в память (по умолчанию рабочая директория при этом не изменяется).

  3. Вносит мутации в определённые участки копий в соответствии с настраиваемым списком разрешённых мутаторов.

  4. Запускает на них ваши тесты и формирует отчёт в различных форматах.

Когда мы говорим об изменениях исходного кода, речь идёт о конкретном допустимом наборе мутаторов: нет смысла изменять код буквально случайным образом, создавая синтаксические или структурные ошибки. Но если заменить:

  • плюс на минус в математических выражениях;

  • true на false в логических выражениях;

  • ссылку на объект на null в операторе return;

  • и так далее

это может помочь безопасно проверить качество ваших тестов.

Полный список мутаторов, реализованных в Pitest, можно найти здесь.

Практика! Напишем немного кода

Создадим простой Maven‑проект всего с одним классом \tools\IntComparator.java.
Затем реализуем единственный метод, который принимает два аргумента типа int, сравнивает их и возвращает «first», если первый больше второго, и «second» — в противном случае:

package tools;

class IntComparator {

    String max(int a, int b) {
        if (a > b) {
            return "first";
        } else {
            return "second";
        }
    }
}

Напишем тест

Для тестирования приложения мы будем использовать JUnit, поэтому необходимо добавить зависимость в pom.xml:

<dependencies>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-engine</artifactId>
            <version>5.8.1</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

После этого создадим тест.
Сценарии тестирования у нас довольно простые:

  1. 5 > 3 → “first”

  2. 3 < 5 → “second”

package tools;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

class IntComparatorTest {

    private final IntComparator intComparator = new IntComparator();

    @Test
    void testMax() {
        assertEquals("first", intComparator.max(5, 3));
        assertEquals("second", intComparator.max(3, 5));
    }
}

Теперь, если запустить тест, вы увидите, что он успешно проходит. Более того, если рассчитать покрытие кода, оно составит 100%.

Означает ли это, что все случаи покрыты?
Нет.

Давайте мутировать!

Добавим зависимость Pitest в раздел dependencies файла pom.xml:

<dependency>
    <groupId>org.pitest</groupId>
    <artifactId>pitest-parent</artifactId>
    <version>1.11.5</version>
    <type>pom</type>
</dependency>

и плагин в раздел build:

    <build>
        <plugins>
            <plugin>
                <groupId>org.pitest</groupId>
                <artifactId>pitest-maven</artifactId>
                <version>1.11.5</version>
                <dependencies>
                    <dependency>
                        <groupId>org.pitest</groupId>
                        <artifactId>pitest-junit5-plugin</artifactId>
                        <version>1.1.1</version>
                    </dependency>
                </dependencies>
                <configuration>
                    <targetClasses>
                        <param>tools.*</param>
                    </targetClasses>
                    <targetTests>
                        <param>tools.*</param>
                    </targetTests>
                </configuration>
            </plugin>
        </plugins>
    </build>

После этого запустим наш новый плагин, чтобы начать мутационное тестирование:

mvn clean verify pitest:mutationCoverage 

Эта команда генерирует два отчёта: текстовый вывод в консоль и дополнительный файл.html в папке target.

================================================================================
- Mutators
================================================================================
> org.pitest.mutationtest.engine.gregor.mutators.ConditionalsBoundaryMutator
>> Generated 1 Killed 0 (0%)
> KILLED 0 SURVIVED 1 TIMED_OUT 0 NON_VIABLE 0 
> MEMORY_ERROR 0 NOT_STARTED 0 STARTED 0 RUN_ERROR 0 
> NO_COVERAGE 0 
--------------------------------------------------------------------------------
> org.pitest.mutationtest.engine.gregor.mutators.returns.EmptyObjectReturnValsMutator
>> Generated 2 Killed 2 (100%)
> KILLED 2 SURVIVED 0 TIMED_OUT 0 NON_VIABLE 0 
> MEMORY_ERROR 0 NOT_STARTED 0 STARTED 0 RUN_ERROR 0 
> NO_COVERAGE 0 
--------------------------------------------------------------------------------
> org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator
>> Generated 1 Killed 1 (100%)
> KILLED 1 SURVIVED 0 TIMED_OUT 0 NON_VIABLE 0 
> MEMORY_ERROR 0 NOT_STARTED 0 STARTED 0 RUN_ERROR 0 
> NO_COVERAGE 0 
--------------------------------------------------------------------------------
================================================================================
- Timings
================================================================================
> pre-scan for mutations : < 1 second
> scan classpath : < 1 second
> coverage and dependency analysis : < 1 second
> build mutation tests : < 1 second
> run mutation analysis : < 1 second
--------------------------------------------------------------------------------
> Total  : < 1 second
--------------------------------------------------------------------------------
================================================================================
- Statistics
================================================================================
>> Line Coverage (for mutated classes only): 4/4 (100%)
>> Generated 4 mutations Killed 3 (75%)
>> Mutations with no coverage 0. Test strength 75%
>> Ran 4 tests (1 tests per mutation)
Enhanced functionality available at https://www.arcmutate.com/
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------

Здесь мы видим список мутаторов, которые использовались для проверки нашего кода: мутанты, созданные NegateConditionalsMutator и EmptyObjectReturnValsMutator, были успешно убиты.

Это означает, что Pitest заменил > на < в строке 6 файла IntComparator.java, после чего первая проверка в нашем тесте завершилась ошибкой, поскольку мутант возвращает «second» вместо «first».

В случае другого мутатора код возвращает «» вместо ожидаемой строки, что также было обнаружено тестом, а мутант — убит.

В.html‑отчёте также можно найти список активных мутаторов.

Что касается теста, который не смог обнаружить мутацию, созданную ConditionalsBoundaryMutator, этот мутатор изменяет границы условий.
Условие было изменено с a > b на a >= b. Этот мутант выжил, потому что в тесте мы не проверяем ожидаемый результат для случая, когда a == b.

Убьём мутанта!

Просто добавим недостающую проверку в модульный тест:

@Test
void testMax() {
    assertEquals("first", intComparator.max(5, 3));
    assertEquals("second", intComparator.max(3, 5));
    assertEquals("second", intComparator.max(4, 4));
}

И повторно запустим плагин Pitest:

Отлично! Мутационное тестирование наглядно выявило уязвимость и помогло улучшить наш тест. Так почему же MT до сих пор не стало отраслевым стандартом?

Проблемы в реальных проектах

Ложные срабатывания

Я немного изменю приведённый выше пример, чтобы продемонстрировать одну из проблем.
Теперь тестируемый метод будет возвращать результат типа int вместо String. При этом все проверки в тесте мы сохраним.

IntComparator:

int max(int a, int b) {
    if (a > b) {
        return a;
    } else {
        return b;
    }
}

IntComparatorTest:

@Test
void testMax() {
    assertEquals(5, intComparator.max(5, 3));
    assertEquals(5, intComparator.max(3, 5));
    assertEquals(4, intComparator.max(4, 4));
}

Посмотрим на результаты:

Что произошло? Ведь мы покрыли все случаи! Но у нас снова остался выживший мутант. В примере выше, метод max действительно написан корректно, так как неважно, что возвращать a или b, если a == b. Но тест не упал! А значит, мутант выжил.

Это типичный пример ложноположительного результата, когда тест оказывается строже, чем действительно необходимо. Если поискать информацию в интернете, можно увидеть, что это довольно распространённая претензия к мутационному тестированию.

Производительность

Мутационное тестирование — небыстрый процесс. Это не секрет, и не стоит питать по этому поводу иллюзий. На главной странице Pitest прямо говорится, что инструмент работает быстро, но тут же поясняется, что именно в данном контексте подразумевается под «быстро».

Экспериментируя с различными наборами мутаторов, используя несколько потоков или благодаря оптимизациям внутри движка — например, за счёт отказа от создания бесполезных поглощаемых мутантов — можно заметно сократить время выполнения мутационного тестирования. Однако оно всё равно останется существенным и добавит к процессу тестирования минуты, а иногда и десятки минут.

Также рекомендую эту статью о практическом опыте использования мутационного тестирования и, в частности, об анализе его производительности.

Рекомендуемый способ использования

В декабре 2016 года Java Magazine опубликовал статью Генри Коулза, автора Pitest, под названием «Mutation Testing: Automate the Search for Imperfect Tests». В ней он рассматривает возможность применения мутационных тестов в реальных крупных проектах, и если свести основную мысль к одному предложению, она будет звучать так:

Единственный код, для которого вам действительно нужно выполнять мутационное тестирование, — это код, который вы только что написали или изменили.

Позже эта идея получила дальнейшее развитие в статье «Don't let your code dry», которая сейчас опубликована в блоге Pitest.

Если говорить о реализации этого подхода непосредственно в Pitest, автор объясняет, как на практике можно организовать инкрементальное тестирование: с помощью локального запуска плагина — который, кстати, интегрирован с GIT и может работать только с добавленными или изменёнными файлами, — либо запускать MT во время анализа pull request.

Таким образом, можно сказать, что добавлять мутационное тестирование всего проекта в CI‑пайплайн, вероятно, не лучшая идея. С другой стороны, инкрементальный подход позволяет минимизировать влияние описанных выше проблем и сделать мутационное тестирование гораздо более интересным инструментом.

Заключение

Мутационное тестирование ни разу не серебряная пуля!

Мы называем тесты «сильными», если они обеспечивают высокий уровень покрытия мутаций, поскольку такие тесты способны обнаруживать и убивать большинство мутантов, что свидетельствует об их эффективности в выявлении потенциальных ошибок в коде.

Однако даже если тесты сильные, это ещё не означает, что они хорошие. Будут ли они проходить, если реализация изменится, а поведение останется прежним? Есть ли у них запахи? Быстро ли они выполняются и не создают ли лишних накладных расходов?

В целом сильные тесты действительно являются хорошим показателем эффективности тестирования, однако для того, чтобы убедиться в надёжности и полноте тестов, необходимо учитывать и другие факторы.

Моя личная проблема с мутационным тестированием заключалась в завышенных ожиданиях. Сама концепция выглядит очень увлекательно, но чем глубже погружаешься в детали, тем больше замечаешь ограничений. Главное из них всегда лежит на поверхности: инструменты тестирования не знают логику вашего приложения, и какие бы методы вариативности или прогнозирования они ни использовали, в конечном итоге они лишь предоставляют вам результаты для анализа. Окончательное решение о качестве тестов остаётся за разработчиком или командой. И мутационное тестирование будет эффективно работать ровно до тех пор, пока вы готовы внимательно изучать его отчёты и разбирать каждый выявленный им случай.

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

В любом случае я продолжу внимательно следить за развитием этой идеи и, в частности, проекта Pitest. Уже сейчас это зрелый продукт, интегрированный с GIT, Eclipse, IntelliJ, Sonar и другими инструментами. Меня также вдохновляют уровень активности вокруг проекта и поддержка со стороны его автора и сообщества.

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.