Mutation testing на примере Pitest

  • Tutorial
Многие из вас, возможно, слышали про Mutation Testing в замечательном подкасте «Разбор полётов» или читали в википедии. Для тех, кто всё-таки с понятием пока не знаком, в двух словах объясню.

Мутационное тестирование — альтернативный подход к измерению качества ваших тестов. Вместо того, чтобы считать банальный code coverage, используется более разумный механизм. В байт-код ваших классов внедряются случайные изменения, иначе называемые мутациями. Если после такой мутации не упал ни один тест, который покрывает внесённые изменения, то велика вероятность того, что с тестами у вас не особо-то и хорошо. Пример возможной мутации:
Было:
if(somevalue < threshold) {
    doSomething();
}
Стало:
if(somevalue >= threshold) {
    doSomething();
}
Изменение довольно критичное, потому тест, покрывающий этот блок кода, наверняка должен упасть. Под катом я расскажу о весьма хорошей библиотеке Pitest, покажу, как её подключить к своему проекту, и приведу результаты тестирования на реальном коде.

Простенький проект

Начнём с простого проекта[github], содержащего один-единственный класс:
1
2
3
4
5
6
8
public class ClassToTest {
    private static final double THRESHOLD = 10.0;
    
    public static boolean threshold(double value) {
        return value >= THRESHOLD;
    }
}
и тест на него:
1
2
3
4
5
@Test
public void testThreshold() {
    Assert.assertTrue(ClassToTest.threshold(10.0));
    Assert.assertFalse(ClassToTest.threshold(9.0));
}


Для того, чтобы подключить pitest, достаточно добавить его плагин в maven:
1
2
3
4
5
6
7
8
9
10
11
12
13
<plugin>
    <groupId>org.pitest</groupId>
    <artifactId>pitest-maven</artifactId>
    <version>0.25</version>
    <configuration>
        <inScopeClasses>
            <param>com.example*</param>
        </inScopeClasses>
        <targetClasses>
            <param>com.example*</param>
        </targetClasses>
    </configuration>
</plugin>
Чуть подробнее по конфигурации: inScopeClasses определяет те классы, в которых следует искать тесты и классы, которые следует подвергать мутации. targetClasses определяет те классы, которые следует подвергнуть только мутации. Кроме того, есть ещё некоторые опции, полный список которых можно посмотреть тут.

Если вы по какой-то причине не используете maven, то ещё не всё пропало: можно пользоваться и из командной строки, руководство доступно тут.

А чтобы обрести счастье, используя maven, достаточно выполнить команду:
mvn org.pitest:pitest-maven:mutationCoverage

Понимание отчётов

В результате проверки мы получим довольно-таки большую простыню логов. Читать её не особо удобно, но зато в папке target/pit-reports/%TIMESTAMP% генерируется и html-отчёт, похожий на code coverage. В нашем случае интересная его часть будет выглядеть примерно так:



Цифра три возле строки 14 тут означает, сколько мутаций было к этой строчке применено. Далее в разделе mutations для каждой строчки описывается, какие были применены мутации, и каков был результат.

Результат выполнения мутации

  • KILLED — в результате мутации упали все тесты, проверяющие эту строку. Можно заметить, что у нас все мутации имеют такой статус, что довольно хорошо.
  • SURVIVED — мутация прошла незамеченной. Это значит, что изменение в функциональности не покрыто тестами
  • TIMED_OUT — тест работал слишком долго (например, в результате возникновения бесконечного цикла)
  • NON_VIABLE — получившийся в результате мутации бат-код по какой-то причине оказался не валидным (случается довольно редко)
  • MEMORY_ERROR — в результате мутации код стал потреблять слишком много памяти и упал с OOM
  • RUN_ERROR — в результате мутации получился код, генерирующий исключение

Типы мутаций


На данный момент есть всего 11 мутаций. Зелёным выделены те, которые включены по умолчанию.
  • CONDITIONALS_BOUNDARY — в проверках меняет строгие неравенства на нестрогие и наоборот. Например, < превратится в <=
  • NEGATE_CONDITIONALS — в проверках инвертирует условия. Например, == превратится в !=
  • MATH — заменяет используемые математические операторы. Например, меняет минус на плюс.
  • INCREMENTS — заменяет инкременты на декременты и наоборот
  • INVERT_NEGS — инвертирует знак целым и вещественным числам
  • INLINE_CONSTS — меняет литералы, подставляя на их место другое значение. Например, вместо 42 будет подставлено 43, а вместо true будет подставлено false
  • RETURN_VALS — подменяет значение, возвращаемое методом, на какое-то другое. Например, вместо полноценного объекта будет возвращаться null.
  • VOID_METHOD_CALLS — удаляет вызовы void-методов
  • NON_VOID_METHOD_CALLS — вместо вызовов не-void методов возвращает значение по умолчанию для типа этого метода (false, 0, null)
  • CONSTRUCTOR_CALLS — вместо вызова конструктора использует null
  • EXPERIMENTAL_INLINE_CONSTS — похож на INLINE_CONSTS, но несколько умнее
Детальное описание различных типов мутаций доступно на официальном сайте.

Усложняем пример


Получается, что в нашем sample-проекте мутации были в условии (2 шт) и в возвращаемом значении. Попробуем теперь добиться большего количества мутаций. Перепишем сам класс так:
1
2
3
4
5
6
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class ClassToTest {
    
    private int invocationCount = 0;

    private static final double OFFSET = 1.0;
    private final double threshold;
    
    public ClassToTest(double threshold) {
        this.threshold = threshold;
    }
    
    public boolean threshold(double value) {
        logInvocation();
        return value >= threshold + OFFSET;
    }

    private void logInvocation() {
        invocationCount++;
    }

}
Но вот в тестах новую функциональность тестировать не будем:
1
2
3
4
5
6
7
@Test
public void testThreshold() {
    ClassToTest classToTest = new ClassToTest(10.0);

    Assert.assertTrue(classToTest.threshold(11.0));
    Assert.assertFalse(classToTest.threshold(10.0));
}
При запуске code coverage никаких проблем выявлено не будет. А вот если мы запустим mutation testing, то нас быстро схватят за руку и скажут: а функциональность-то не протестирована!



Успех! Теперь мы довольно точно можем сказать, какой код действительно протестирован, а какой нет, и всякие «якобы» тесты, которые на самом деле ничего не проверяют, быстро будут обнаружены.

Почему именно pitest?

Идея мутационного тестирования, вообще говоря, не нова, и несколько библиотек уже существовало. Наиболее примечательные из них — Javalanche и Jumble. Однако и они, и другие библиотеки не особо активно развиваются, некоторые из них тормозны и глючны, и практически не имеют интеграции с системами сборки и другими библиотеками. Подробное сравнение доступно тут.

Проверим на реальном проекте

Для пущей интересности правильно было бы на каком-нибудь реальном проекте продемонстрировать, как mutation testing находит проблемы, которые не находит code coverage. Отлично для этого подойдёт cobertura — утилита, считающая code coverage. Её отчёт может быть найден в полном виде тут, а я приведу лишь маленький кусочек. Чтобы его получить, пришлось немного попотеть с добавлением поддержки maven в исходники и подождать минут двадцать, пока будет идти мутационное тестирование.. Результат получился таким.
Cobertura показывает, что всё хорошо:
Pitest срывает покровы:


Итого

Итого, подход классный, и явно гораздо более точно оценивает качество тестов, чем code coverage. Конечно, такие проверки и работают существенно дольше, чем обычный coverage, и потому на больших проектах могут занимать часы. Кроме того, сама библиотека Pitest пока несколько сыровата. Например, нет возможности проводить тестирование в несколько потоков, или обязательно успешное выполнение всех тестов без мутаций. Проект, впрочем, opensource, и весьма активно развивается, так что я полагаю, что через какое-то время можно будет начать думать о том, чтобы использовать его всерьёз.

Жду ваших вопросов, замечаний и исправлений в комментариях!
Поделиться публикацией

Похожие публикации

Комментарии 43

    +11
    <x-man> А если программа, после мутации мутирует в другую, более полезную программу? </x-man>

    На самом деле захотелось проверить Mutation testing на чём-нибудь своём. Спасибо за статью.
      +3
      > <x-man> А если программа, после мутации мутирует в другую, более полезную программу? </x-man>

      Да, можно использовать такой подход для автоматизированных багофиксов:
      1. Написать тесты, покрывающие необходимую функциональность.
      2. Запустить сабж, и при полезных мутациях (т.е. при прохождении тестов) оставлять внесенные изменения.
        +5
        Так и будем наверное программировать лет через 50.
          0
          thisAlgorithmBecomingSkynetCost=999999999
          +1
          кул, генетический алгоритм, пишущий программы. юнит-тесты в качестве внешних факторов, выживают только наиболее рписпособленные особи исходники
            0
            И вроде квантовый компьютинг сюда хорошо ложится.
          +1
          Список мутаций слишком маленький. И в нём нет внесения новых инструкций :)
          +1
          Интересный подход. Можно сказать возводит в абсолют принцип «для каждой куска кода должна быть единственная причина его написания — сфейливший тест». А тут можно сказать для каждого символа.
            0
            Ну вот только возводить этот принцип в абсолют, имхо, не самая лучшая идея.
            Не буду же я тестировать, например, логирование. И он мне покажет Survived на каждом вызове лог-функции.

            Но смотреть и анализировать результаты время от времени, безусловно, полезно :)
              –1
              Не покажет, если включить INLINE_CONSTS :) Содержание лога будет не соответствовать ожидаемому.
            +1
            Мутации производятся стохастически или по какому-то четко определенному плану/критериям?
              0
              Проводятся для каждой операции все возможные мутации.
                +2
                ИМХО, для большого проекта полный расчет оценки займет нереально много времени.
                  0
                  Да, это так. Но процесс можно распараллелить, а в скором времени обещают инкрементальное тестирование.
              +3
              А сяду-ка я, и напишу такой мутатор на Roslyn для сишарпа. Должно интересно получиться!
                +1
                не забудьте отчитаться о результатах!
                  0
                  как все прошло?
                  0
                  Я правильно понимаю, что для каждой мутации pitest пересобирает тестируемую программу? Как-то грустно от мысли, что в случае сборки больше 5 минут и порядка 100 мутаций ждать результатов надо будет явно не 5 минут…
                    0
                    Нет, не правильно. Модифицируется байт-код в памяти виртуальной машины (т.е на диск изменения никогда не попадают). По крайней мере, в pitest так.
                      –1
                      А если после оптимизации уже нет в итоговой программе того места, что соответствовало, скажем, операции сравнения в исходном коде?
                        0
                        Не понял вас. Байт-код практически полностью соответствует исходному коду. Оптимизаций javac делает очень мало. Мне кроме инлайнинга констант ничего на ум даже не приходит.
                          0
                          То есть компилятор java практически не делает оптимизаций? Не знал этого…
                            0
                            Для оптимизации есть JIT, а javac лишь транслирует java-код в байт-код.
                              –1
                              Спасибо за разъяснение. Пытался понять, применим ли данный подход к C/C++.
                                0
                                Судя по опыту, компилятор и мёртвый код не трогает. Зато используемый обфускатор (proguard) заодно и оптимизирует хорошо — инлайнит и наоборот, выделяет, не забывая отбрасывать мёртвый код.
                    0
                    а для дотнета такое есть?
                    0
                    А для остальных JVM языков оно интересно как заработает? Groovy, Clojure, Scala
                      +1
                      Я не очень в теме, но по-моему без разницы какой java-based язык, итоговый байт код будет совместим.
                        –1
                        Ну тут главное чтобы он менял только ту часть которая была в исходном коде, а не та которая появилась при приведении в байткод
                          0
                          Что? Я вас не понял. Изменяется именно байт-код. Какие, по-вашему, «части кода» появляются при трансляции в байт-код?
                            0
                            А какая ему разница. Менять opcode инструкции например if сгенерированной из исходников Java или Groovy, Clojure, Scala. Я лично не вижу разницы )
                              0
                              Ну вот для случая (groovy)

                              if (user?.facebook?.friends) {
                              // ....
                              }


                              в байткоде будет чтото типа:

                              Object x1 = null
                              if (user != null) {
                              x1 = user.facebook
                              }
                              Object x2 = null
                              if (x1 != null) {
                              x2 = x1.friends
                              }
                              if (x2 != null) {
                              // ....
                              }

                              и для данного случая проверять первые два if особого смысла нет
                                0
                                Поменять, наверное, мало. Нужно же соотнести место изменения байт-кода и строку исходников.
                                  0
                                  Боюсь что это невозможно ибо в байт-коде нет ссылок на исходный код. Т.е. соотнести байт-код с исходным кодом я думаю невозможно
                                    0
                                    С точностью до строки — возможно. Как, по-вашему, code coverage работает? И как работают исключения?
                                      0
                                      Нет, ну понять что ф-ция вызывалась или такое-то условие сработало то да, но восстановит информацию какой строке исходного кода это соответствует я думаю невозможно
                                        0
                                        Если скомпилировать class-файл с информацией о соответствии смещений номерам строк (ключ -g:lines ), то восстанавливать ничего не придётся. Будет достаточно это прочитать.
                                        Собственно, по умолчанию генерация этой информации включена.
                                      0
                                      В посте отчёт, там как-то соотносят. Да и по логике, байт-код это же не результат хэш-функции от исходников, а обратимая (пускай и частично) трансляция. И соответствие байта в коде и конкретного места в исходниках вполне однозначное и двустороннее (если не было оптимизаций компилятора).
                            0
                            Жутковато как-то.
                            Ведь в результате получается код, который делает неизвестно что.

                            Я понимаю, formatDate на format C: наверно не заменит так вот сходу (-:, но тем не менее, в файловых операциях например есть определённая вероятность поиметь неприятности теоретически. Нет?

                            Ну и даже если не if(somevalue < threshold) на if(somevalue>=threshold), а for(;somevalue<threshold) на (;somevalue>=threshold) заменить — много уже словило бесконечных циклов в процессе проверки?

                            Когда тесты тестируют написанный программистом код, и что-то случается нехорошее в результате (пропадают файлы в файловой системе или ключи в реестре, например), то виноват программист.
                            Когда же тесты тестируют машинно поломанный код, и что-то неожиданное происходит — кого винить, и стоит ли это вообще считать за повод, разбираться с кодом и искать проблемы?
                              0
                              P.S. Ах да, про бесконечные циклы вы же писали — в таком случае тест просто не даёт однозначных результатов (кто знает, по чьей вине таймаут — код ли плохой изначально, или мутация подкачала).
                              Ну что ж, полезность становится несколько ограниченной…
                                0
                                Мне кажется, что вы не поняли сути происходящего. Мутационное тестирование не выявляет качество кода. Оно выявляет только качество тестов. Так что если таймаута без мутации нет, а с мутацией есть, то значит, что получилась неудачная мутация, вот и всё. Если вы внимательно читали, то заметили, что мутационное тестирование применяется только к успешно проходящим тестам.
                                  0
                                  Хм, пожалуй я был не прав — здесь таймаут таки свидетельствует о покрытии тестом кода.

                                  Но, тем не менее, и без того приличное время мутационного тестирования эти циклы+таймауты явно еще увеличивают )-:

                            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                            Самое читаемое