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

Введение

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

Пирамида тестов
Пирамида тестов

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

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

  • Покрытие заявлений

  • Покрытие решений

  • Покрытие ветвей

  • Покрытие переключений

  • Покрытие FSM (Finite State Machine. Конечный автомат состояния)

Когда у вас есть метрика, можно установить цель. Например, в Sipios мы считаем, что по крайней мере 80% ветви должны быть покрыты, иначе вы не сможете объединить свой код. Но нужно быть осторожным при достижении этого предела: низкое покрытие кода означает недостаточное тестирование, а высокое покрытие не гарантирует высокого качества тестов.

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

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


Что такое мутационное тестирование? 

Мутационное тестирование было первоначально предложено Ричардом Липтоном в 1971 году. Согласно Википедии, оно основано на двух гипотезах:

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

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

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

Генерация мутантов

Первый шаг заключается в генерации другой версии вашего кода. Если вы знакомы с генетическим алгоритмом (ГА), используемым в задачах оптимизации и поиска, его можно рассматривать как шаг инициализации при генерации популяции.

Этому методу требуется только ваш код и набор операторов мутации. Затем необходимо по одному использовать эти операторы в исходном коде для каждого применяемого заявления программы. Результат применения одного оператора мутации к программе называется мутантом. Обычно используются следующие операторы мутации:

  • Удаление, дублирование или вставка заявлений.

  • Замена булевых подвыражений на true и false

  • Замена одних арифметических операций на другие, например, + на *, - на /

  • Замена одних булевых соотношений на другие, например, > на >=, == и <=   

  • Замена переменных на другие из той же области видимости (типы переменных должны быть совместимы).

  • Удаление тела метода, реализованное в Pitest (мы обсудим Pitest позже).

Например, если вы используете только оператор, заменяющий * на / в следующем методе:

public int multiply(int a, int b) {
 return a * b;
}

Вы получите замечательный метод деления, который следует далее:

public int multiply(int a, int b) {
 return a / b;
}

Мутанты, генерируемые с помощью двух или более операторов, называются мутантами высшего порядка (HOM). Мы не будем обсуждать тестирование HOM в этой статье, но можно найти интересные работы о том, как их можно эффективно генерировать.

Убейте их всех

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

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

Чтобы разобраться в этом, давайте представим, что мы протестировали наш метод умножения с помощью следующего теста:

@Test
public void multiplyInts() {
    assertEquals(7, multiplicationService.multiply(7, 1));
}

Согласно покрытию кода, метод multiply покрывается на 100%, но мутант, который является методом divide, выживет. В этом случае он выдаст нам 0% результата мутации. Надеюсь, мы сможем добавить тест, который умножит 2 и 3, чтобы получить 100% оценку мутации.

Теперь, когда мы знаем основы, давайте посмотрим, как это работает на практике. 

Как выполнить мутационное тестирование? 

В этом разделе вы узнаете, что такое Pitest и как его использовать в java-проекте с помощью maven. Мы также рассмотрим альтернативные варианты.

Что такое Pitest? 

Согласно сайту pitest.org:

PIT — это современная система мутационного тестирования, обеспечивающая золотой стандарт тестового покрытия для Java и jvm. Она быстра, масштабируема и интегрируется с современными инструментами тестирования и сборки.

Как ее использовать?

Установка с помощью maven проста и выполняется с помощью maven quickstart. Другие quickstart для gradle, ant или командной строки можно найти здесь.

По сути, нужно только добавить плагин в build/plugins вашего pom.xml

<plugin>
    <groupId>org.pitest</groupId>
    <artifactId>pitest-maven</artifactId>
    <version>LATEST</version>
 </plugin>

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

<configuration>
    <targetClasses>
        <param>fr.service.MultiplicationService</param>
    </targetClasses>
    <targetTests>
        <param>fr.service.MultiplicationServiceUnitTest</param>
    </targetTests>
</configuration>

Затем вы можете сгенерировать полный HTML-отчет, используя цель mutationCoverage с помощью команды:

mvn org.pitest:pitest-maven:mutationCoverage

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

Результаты

Отчеты, создаваемые PIT, имеют удобный для чтения формат, объединяющий информацию о покрытии строк и покрытии мутаций. Их можно найти в папке target/pit-reports/YYYYMMDDHHMI .

В нашем примере мы получаем 100% покрытие линии, соответствующее 50% результату мутации.

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

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

После добавления этого нового теста мы можем установить минимальный порог покрытия мутаций, добавив опцию -DmutationThreshold следующим образом:

mvn org.pitest:pitest-maven:mutationCoverage -DmutationThreshold=85

Другие инструменты

Если вы не используете Java, я рекомендую вам ознакомиться с 21 лучшими проектами по мутационному тестированию с открытым исходным кодом

Мой путь в мутационном тестировании

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

Избегайте распространенных ошибок

Создание слишком большого количества мутантов

Генерация мутантов для каждого заявления вашего кода с использованием нескольких операторов мутации приведет к созданию целой армии мутантов. Затем вам нужно будет запустить тесты на каждом мутанте. Это процесс, требующий большого количества вычислений, и если не быть осторожным, то придется долго ждать окончания анализа. 

Я работаю над проектом, в котором более 15 микросервисов, и добавил конфигурацию Pitest в родительский pom.xml. Начал, не ориентируясь на какой-то класс или пакет, потому что юнит-тесты могут быть размещены в различных подпроектах по-разному. Это сгенерировало более 5 000 мутантов на микросервисы.

Определение бесполезных мутантов

Некоторые мутанты неинтересны, особенно те, которые генерируются из DTO (Data Transfer Object). Pitest может генерировать мутации на метод, предоставленный аннотацией, например @Data из библиотеки Lombok. Таких мутантов лучше избегать, потому что в большинстве случаев вы не будете переопределять метод, предоставляемый аннотацией.

Включение интеграционных тестов

Интеграционные тесты занимают больше времени, чем юнит-тесты. По умолчанию Pitest использует таймаут в 4 с, чтобы избежать блокировки в бесконечном цикле. Если ваши интеграционные тесты медленные, то для каждого из них может потребоваться время ожидания 4 с, умноженное на количество сгенерированных мутантов. Другими словами, это может занять несколько дней. Не пытайтесь это сделать, пожалуйста.

Даже если он будет завершен, нужно иметь возможность прочитать отчет.

Вы можете попытаться сгенерировать отчет по всему коду, используя все тесты. Это займет слишком много времени, чтобы использовать его в автоматическом процессе (мне потребовалось 23 секунды на сервисе с 15 юнит-тестами), и у вас будет слишком много мутантов, которые выживут. Представьте, что у меня 90% результат мутации на моих 5000 мутантов, и в результате у меня останется еще 500 мутантов для анализа. Я думаю, что проще начать с малого, а затем попробовать сгенерировать способ анализа отчета. Процесс проведения мутационного тестирования и анализа отчета отнимает много времени.

Используйте мутационное тестирование с умом

Во-первых, вам нужно найти интересующий вас фрагмент. В моем случае это был сервис из API, который был "принадлежностью" моей команды. То есть, мы отвечаем за эту часть кода. 

Я выбрал сервис, потому что это место, где должна быть реализована логика в API. Выбранный мной сервис был хорошим куском, потому что код насчитывает более 1 000 строк, и у него, согласно git blame, не менее 18 соавторов. Самой интересной частью этого сервиса было то, что некоторые фрагменты кода были написаны более года назад, а отдельные строки — всего 2 недели назад. 

И последнее, но не менее важное, этот сервис имеет 96% покрытие строк и 93% покрытие ветвей при выполнении всех тестов.

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

Давайте сломаем все

Анализ оценок

Первое, что я увидел после генерации отчета, это то, что у нас только 50% покрытие линии юнит-тестами и 34% по результатам мутаций

Я был разочарован 50% покрытием, поскольку казалось, что в данном случае код плохо протестирован, однако это можно легко объяснить. Действительно, чаще всего мы добавляем методы в сервисы с целью создания новых маршрутов для контроллера. В этом случае многие склонны сначала проводить интеграционные тесты. Поскольку интеграционные тесты используют сервис и его методы, то у вас будет высокий показатель покрытия кода. Когда достигается стандарт 80% покрытия кода, вы уже не думаете о написании юнит-тестов, потому что метрики показывают, что вы хорошо выполнили свою работу. 

Я не знаю, как анализировать результат мутации в 34%. Кажется, что это не так уж плохо по сравнению с покрытием кода. Кроме того, я проверял, что интеграционные тесты могут убить некоторых мутантов. В действительности у нас менее 66% мутантов, которые могут пережить все тесты.

Анализ выживших

Мы убили 39 мутантов из 114. Осталось проанализировать 75 выживших. Это означает, что если я закоммичу и запушу мутанта, мои юнит-тесты на данном сервисе его не увидят. Как я уже объяснял, другие тесты все еще могут убить этих мутантов, так что потребуется много времени, чтобы проверить каждого из них на всех тестах. Нам нужен лучший метод.

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

  • 26 удаление заявлений

  • 22 нулевые возвращаемые значения

  • 19 условных отрицаний

Я предполагаю, что statement deletion — самый простой оператор для анализа. Действительно, достаточно удалить линию и посмотреть, сохраняется ли смысл кода. Чаще всего эти мутанты возникают в сеттер-методах объекта. Иногда они могут возникать в вызовах API, но их всегда отлавливают интеграционные тесты. 

Возвращаемые значения null также легко поддаются анализу и могут нанести огромный ущерб. Интересно, что нулевые возвращаемые значения можно получить с помощью удаления заявления сеттера. Вы можете представить себе создание мутанта более высокого порядка в реальной жизни только путем удаления вызова сеттера? Я думаю, это может быть интересной темой для проверки, когда вы занимаетесь защитным программированием.

Чему я научился?

Я могу это сделать!

Проанализировав всего несколько операторов мутации, мне удалось создать свой первый баг с помощью мутационного тестирования. Это отняло у меня 30 минут и только лишь условное отрицание.

Такой результат вызывает тревогу, потому что при рефакторинге кода можно пропустить подобные ошибки. На практике у нас есть длительный процесс, который позволяет отловить практически любые из них до того, как баг попадет в продакшн. Действительно, перед созданием запроса на слияние вы должны локально протестировать свою функцию. Здесь я бы поймал баг. Затем мы делаем a code review, где мои коллеги должны были заметить ошибку. Затем функция тестируется PO (Product Owner) в the development среде и с помощью QA (Quality Assurance) в preproduction.

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

Делать все правильно с первого раза

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

Я также понял, что мы можем улучшить тестирование, помогая разработчикам быть более точными, при написании теста. Разделение между интеграционными и юнит-тестами должно быть достаточно осознанным, и следует использовать оба этих вида тестов. Предполагаю, что TDD (Test Driven Development) поможет достичь этого, сосредоточившись сначала на юнит-тестах, а не на создании интеграционных тестов. Это увеличило бы мутационный результат. 

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


Материал подготовлен в рамках курса «Kotlin QA Engineer». Если вам интересно узнать подробнее о формате обучения и программе, познакомиться с преподавателем курса — приглашаем на день открытых дверей онлайн. Регистрация здесь.

Комментарии (0)