Мутационное тестирование. Теория + практикум

“Попробуем разобраться, что такое мутационное тестирование, и как эта концепция работает в Pitest. В конце посмотрим, как в реальном проекте находят баги при помощи мутационного тестирования.

Содержание

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

пирамида тестирования

Пирамида тестирования, знакомо? Да, всегда приходится чем-то жертвовать

Тестировщик вносит свой вклад в качество тем, что создает тесты, и в 2021 году это в основном, все-таки юнит-тесты.

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

  • Покрытие операторов
  • Покрытие условий
  • Покрытие ветвлений (ветвей)
  • Покрытие переключений
  • Покрытие конечных состояний (т.н. FSM)

Когда уже есть метрика (то есть этот показатель покрытия), можно ставить цели. Например, в нашей компании (Sipios, финтех) мы говорим, что как минимум 80% ветвлений должно быть покрыто, а иначе ты не можешь мержить свой pull request в мастер-ветку. Нужно быть внимательным при достижении этого лимита — малое покрытие означает недостаточное тестирование, а высокое покрытие вовсе не гарантирует хорошего качества тестов.

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

В такой ситуации, для улучшения надежности, иногда применяется такой специфический тип тестирования, как мутационное тестирование.

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

Сама идея отнюдь не нова, впервые предложена одним из гуру 1970х, Ричардом Липтоном, почти 50 лет назад. Как говорит Википедия, она базируется на двух столпах:

Во первых, программист по умолчанию считается очень опытным. Отсюда считается, что в коде грубейших логических ошибок нет!, а 99% ошибок — синтаксические, то есть легко находимые и корректируемые.

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

Процесс мутационного тестирования: 1. Создаются «мутанты». 2. Мутантов пытаются убить. И смотрят, что из этого получается.

Вот так.

Создание мутантов

Итак, процесс начинается с генерирования чуть отличающихся версий кода, что и называется «мутацией». 

Если ты знаешь, что такое Generic Algorithm (GA) применяемый для оптимизации кода и поиска проблемных мест, он может рассматриваться как первый этап в создании «популяции мутантов».

Этот метод требует только твоего кода и выбора мутационных операторов. Далее применяем эти операторы, один раз для каждого выражения в программе. Результат применения мутационного оператора и называется «мутантом». 

Мутационные операторы бывают такими:

  • Удаление выражения. Или его дубликация. Или его вставка.
  • Замена булевых выражений: true на false, и наоборот
  • Замена 4 главных арифметических операторов: + на *, / на -, и так далее
  • Замена булевых соотношений, > на >=, == на <=, и так далее
  • Замена переменных на другие переменные того же типа (но в той же области видимости!)
  • Удаление тела метода. Эта функция хорошо работает в Pitest, чего мы позже коснемся.

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

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

Получим такое:

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

Мутанты, сгенерированные двумя (или более) операторами, называются «мутантами высшего порядка». В этой статье не будем их касаться (в Сети есть и такие разборы, если этот вопрос интересует, пишите в комментариях).

Ликвидация мутантов

Ликвидировать мутантов, как мы знаем из кино, очень просто. А нам сложнее: надо сделать и запустить тесты по «мутированному» коду. Если один из тестов красный — мутант убит. Если все тесты зеленые — мутант выжил.

Когда тесты закончены по всех мутантах, подсчитывается «показатель мутации», или мутационный показатель. Это процент убитых мутантов от общего их количества. Чем больше мутационный показатель, тем более эффективным считается тестовый набор.

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

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

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

Итак базу мы поняли, теперь плотнее к практике.

Мутационное тестирование в Pitest

Далее узнаем что такое Pitest и как его применять на Java-проектах с Maven. (Есть и альтернативы).

Что такое Pitest

Официальный сайт гласит, что это:

Выдающаяся система мутационного тестирования, своего рода золотой стандарт оценки реального тестового покрытия для Java и вообще для всей платформы JVM. Быстрый, масштабируемый и легко интегрируемый инструмент для тестирования и билда.

Как работает

Установка и подключение к Maven простое (maven quickstart), еще есть квикстарты для Gradle, Ant, ну или для командной строки — все хорошо описано здесь по ссылке.

Надо добавить плагин в список билдов/плагинов в pom.xml:

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

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

<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 требует выполнять анализ мутационного теста еще раз, даже при полностью “зеленом” тестовом наборе, так что придется запустить тесты еще раз, чтобы убедиться что все работает хорошо.

Разбираем результаты

Результаты Pitest как уже сказано выдаются в читаемом формате, где сочетается покрытие по строчкам (Line Coverage) и мутационное покрытие (Mutation Coverage). Отчеты сбрасываются в target/pit-reports/YYYYMMDDHHMI

В нашем случае мы достигли 100% покрытия по строчкам, при 50%-ном мутационном покрытии (скриншот):

мутационное тестирование покрытие

Можно уточнить покрытие по каждому классу, кликая по ним. Pitest показывает, какие мутанты выжили, и какие мутационные операторы к ним применялись:

результаты мутационное тестирование

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

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

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

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

Если работаешь не с Java, советую посмотреть список опенсорсных проектов для мутационного тестирования, там есть много чего, от JS до Rust.

Личные впечатления

Расскажу о своем достаточно длительном опыте с мутационным тестированием. Нет 100% гарантии, что создание мутантов и их отлов всегда и везде улучшает качество любых тестов. Об этом дальше.

Основные ошибки

Слишком много мутантов

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

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

Избегай бесполезных мутантов

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

Мутация интеграционных тестов

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

Репорт уже нечитаемый

Можешь сгенерировать отчет по всему коду и по всем тестам. Это заберет много времени, и это то что иногда бесит в сфере автоматизации (например у меня заняло 23 секунды протестировать микросервис с лишь 15-тью юнит-тестами) и у тебя будет слишком много выживших мутантов.

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

Мутационное тестирование направлено на критические места

Сначала надо найти критические места в проекте. В моем случае, это был небольшой API-сервис, который делала моя команда.

Я выбрал этот сервис, поскольку это была лог API. Выбранный сервис был подходящим с точки зрения кода, поскольку в нем было лишь около 1000 строчек, и в него вносили правки 18 разработчиков (то есть код должен был быть прямо-таки переполнен случайными ошибками). Круче всего было, для мутационного тестирования, что определенные части кода были написаны больше года назад, а некоторые — лишь на прошлой неделе. 

И да, этот сервис был “официально” покрыт, с точки зрения количества строчек, на 96%, и с точки зрения ветвлений на 93%.

Это был типичнейший API-сервис, куда разработчики вносили свои изменения довольно быстро, и QA-команда проверяла их. Итак, мы попытались внести изменения, и посмотрели, «поломается» ли код.

Разбираем свои результаты

Анализ показателей

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

Я удивился такому низкому покрытию, потому что код выглядел плохо оттестированным, но в принципе это же понятно. Мы постоянно добавляли в код новые методы. Дальше мы попытались провести интеграционные тесты. Интеграционные тесты как правило активно задействуют весь сервис, со всеми методами, итого получается высокое покрытие. Когда достигается уровень покрытия 80% (неформальный стандарт в нашей индустрии) — ты не спешишь писать юнит-тесты, потому что видишь хорошую метрику 80%, а она говорит, что в общем-то, к покрытию формальных претензий нет. 

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

Смотрим, кто выжил

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

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

  • 26 удаленных значения
  • 22 нулевых возвращенных значений
  • 19 инвертированных условных операторов

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

Нулевые возвращаемые значения (Null return values) — также несложные для анализа. Они, кстати, могут создавать грандиозные проблемы разработчику. Такие значения могут возникать при удалении сеттеров. Например создание целого каскада “мутантов высокого порядка” случайным удалением сеттера. Считаю, было бы любопытно проверить это, в рамках так называемого “безопасного программирования”.

Мутационное тестирование эффективно

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

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

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

Итоги

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

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

Мутационное тестирование я буду применять в своем будущем рефакторинге. К сожалению, еще не хватает хороших инструментов для анализа результатов.”

Какой была ваша первая зарплата в QA и как вы искали первую работу?

Мега обсуждение в нашем телеграм-канале о поиске первой работы. Обмен опытом и мнения.

1 КОММЕНТАРИЙ

Подписаться
Уведомить о
guest

1 Комментарий
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии
Ева
Ева
2 лет назад

Большое спасибо за статью! Впервые нашла материал, где подробно и понятно рассказывается про мутационное тестирование.

Мы в Telegram

Наш официальный канал
Полезные материалы и тесты
Готовимся к собеседованию
Project- и Product-менеджмент

? Популярное

? Telegram-обсуждения

Наши подписчики обсуждают, как искали первую работу в QA. Некоторые ищут ее прямо сейчас.
Наши подписчики рассказывают о том, как не бояться задавать тупые вопросы и чувствовать себя уверенно в новой команде.
Обсуждаем, куда лучше податься - в менеджмент или по технической ветке?
Говорим о конфликтных ситуациях в команде и о том, как их избежать
$1100*
медианная зарплата в QA в июне 2023

*по результатам опроса QA-инженеров в нашем телеграм-канале

Собеседование

19%*
IT-специалистов переехало или приняло решение о переезде из России по состоянию на конец марта 2022

*по результатам опроса в нашем телеграм-канале

live

Обсуждают сейчас