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

в 22:40, , рубрики: code coverage, java, junit, метки: , ,

JAVA / Mutation testing на примере Pitest Многие из вас, возможно, слышали про 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. В нашем случае интересная его часть будет выглядеть примерно так:

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

Цифра три возле строки 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, то нас быстро схватят за руку и скажут: а функциональность-то не протестирована!

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

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

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

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

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

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

Cobertura показывает, что всё хорошо:
JAVA / Mutation testing на примере Pitest
Pitest срывает покровы:
JAVA / Mutation testing на примере Pitest

Итого

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

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

Автор: gvsmirnov


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js