Мутационное тестирование: опыт внедрения на 1500 сервисов

Привет,  Хабр! Меня зовут Александр, я разработчик в юните, который является центром экспертизы по качеству в Авито. Мы помогаем командам с внедрением эффективных и современных подходов тестирования, а также разрабатываем инструменты для тестирования и управления качеством.

В этой статье я расскажу про мутационное тестирование:

  • Что это вообще такое.

  • Почему и для чего мы его внедрили.

  • Как мы внедряли изменения на всю компанию одной маленькой командой.

  • Немного про реализованный инструментарий.

  • А также про полученный в процессе опыт.

image-loader.svg

Предпосылки внедрения 

Однажды мне прилетела задача написать микросервис. По функциональности это была простая CRUD-илка, но выполняющая важную функцию. Она должна была уметь создавать, хранить и редактировать данные, а также отдавать их наружу. Данные — это конфиги. Мы использовали их для одного из самых популярных «бизнесов» нашей команды — выгрузки тестов из кода. По сути, это просто json-объекты, которыми нужно уметь управлять. Сервис должен был предоставить эту возможность.

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

Долго ли, коротко ли, бизнес-логику я реализовал. Чтобы обеспечить качество сервиса, решил воспользовался самым популярным подходом и написать тестов. Написать их столько, чтобы покрыть как можно больше кода. Я достиг показателя в 95% покрытия. 

image-loader.svg

Показалось, что этого достаточно. Я подумал, что когда покрою код тестами практически на 100%, то мне уже ничего не будет страшно, и я смогу сказать, что мой сервис надёжен. Но когда я посмотрел на получившийся показатель поближе, то понял, что тестовое покрытие показывает всего лишь процент кода, который выполняется при запуске тестов. Эта метрика слабо коррелирует с качеством. 

Считается, что чем больше code coverage — тем лучше. И это работает, но не всегда. Когда в проекте вообще нет тестов, то есть code coverage ~0%, — это одно. Мы начинаем писать тесты, их количество растёт, проект становится проще поддерживать. Но когда тестов в проекте уже достаточно, высокое покрытие может ввести в заблуждение и дать ложные надежды, что всё хорошо протестировано и разного рода изменения ничего не сломают. Ведь стопроцентное тестовое покрытие может быть, например, таким:  

image-loader.svg

Тест позитивный, покрытие этой фунции — 100%. Вроде бы всё прекрасно, но такая проверка по сути является некачественной и мало что проверяет.

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

Сейчас в Авито более 80 инженерных команд и более 1500 микросервисов. Компания растёт, приходят новые люди, команд становится всё больше. Команды пишут всё больше и больше кода. Вместе с количеством кода растёт и количество точек отказа, за качеством которых нужно наблюдать. Чтобы обеспечить качество, мы соблюдаем пирамиду тестирования и пишем юнит-тесты. По самым скромным подсчётам, их у нас 25 000+.

Тестов пишем много и, вроде бы, всё прекрасно. Но в большинстве своём тесты, являющиеся ядром качества, пишут не тестировщики, а разработчики. Я сам разработчик, тесты писать умею и люблю, но подозреваю, что есть куда расти, и мои тесты можно сделать лучше и надёжнее.

И вот нам, как команде, которая отвечает за качество, стало интересно: насколько качественные тесты пишут разработчики для своих сервисов. Это важно, потому что в микросервисной архитектуре твой сервис может при падении задеть соседа, а сосед может оказаться с высоким уровнем критичности. Да, есть Graceful Degradation, но сейчас не об этом.

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

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

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

  1. Данный код не покрыт тестами. 

  2. Тесты не смогли отловить такие изменения. 

Для примера давайте попробуем «мутационно протестировать» простую функцию. Допустим, у нас в проекте есть функция проверки совершеннолетия пользователя. Она сравнивает возраст пользователя с возрастом совершеннолетия в стране. И ещё есть позитивный тест, который проверяет работу этой функции. В данном случае покрытие — 100%:

image-loader.svg

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

Мутант раз. Первое изменение — замена знака «больше или равно» на «больше». Одно такое изменение называется мутацией. А код, который получился в результате, называется мутантом. Мы запускаем тест, и — о чудо — он зелёный. Тест не отреагировал на такое изменение в коде. Хотя в знаке и кроется вся бизнес-логика нашей функции.

image-loader.svgimage-loader.svg

Мутант два. Дальше мы можем изменить цифру, отвечающую за возраст, в меньшую сторону. Тест снова ничего не отловил:

image-loader.svgimage-loader.svg

Мутант три. Напоследок увеличим цифру. И этого наши тесты тоже не отловили:

image-loader.svgimage-loader.svg

Интерпретация произошедшего — у нас нет тестов на граничные значения, и результаты проведенного вручную мутационного тестирования это подсветили. 

Результаты мутационного тестирования можно измерить в виде показателя mutation Score Indicator (MSI). Для нашего примера он равен 0%, и вот как мы его считаем:

Для функции IsAdult удалось сгенерировать троих мутантов (придумали три атомарных изменения). Ни одного из троих мутантов тесты не отловили, это «сбежавшие» мутанты. Какой процент от общего количества мутантов удалось отловить с помощью тестов?  

Ответ на этот вопрос и будет показателем MSI:

(0/3) × 100% = 0%.

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

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

  • 18-летний пользователь является совершеннолетним.

  • 17-летний пользователь не является совершеннолетним.

  • 19-летний пользователь является совершеннолетним.

image-loader.svg

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

Как мы внедряли мутационное тестирование на всю компанию 

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

  • Есть вполне понятный показатель, на который можно влиять, — MSI.

  • Мутационное тестирование позволяет сделать тесты качественнее или хотя бы подумать на эту тему.

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

  • Найти библиотеку мутационного тестирования для своего языка.

  • Установить и настроить её.

  • Запустить библиотеку и получить результат.

  • Проанализировать отчёт и сделать что-то для исправления ситуации: поправить тесты, провести рефакторинг кода и т.д.

  • Повторять эту процедуру регулярно. 

С виду процесс понятен и прост. Но перед нашей командой встала задача централизованно внедрить его в 80 команд и 1500 сервисов силами троих человек.

Если техническую часть реально сделать за какое-то измеримое время, то с самим процессом сложнее. Ведь нужно каждой из команд объяснить суть, научить пользоваться, помочь настроить процесс внутри и поддерживать его какое-то время. 

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

Мутационное тестирование как сервис: требования

Начали, конечно же, со списка требований к будущему сервису:

  • Сервис должен уметь запускать МТ без непосредственного участия команд.

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

  • Также мы хотели выявлять проблемы на раннем этапе, в идеале до попадания кода в мастер.

  • Нужны понятные и удобные отчёты, чтобы команды могли посмотреть на состояние своих проектов и что-то с ним сделать. 

  • Наличие общей статистики и возможности посмотреть положение дел в отдельных командах. 

Проверка гипотезы

Задумка показалась нам интересной и перспективной, но было опасение, не зря ли мы затеяли пилить космолёт. Могло оказаться так, что наши юнит-тесты изначально прекрасного качества и мутационное тестирование не подсветит никаких точек роста. Поэтому было решено проверить гипотезу и сделать MVP.

Для эксперимента выбрали наши микросервисы на PHP. Ни для кого не секрет, что на нём можно писать как очень хорошо, так и не очень хорошо. Плюс в Авито этот язык давно, на нём есть много проектов разного уровня. Мы прикинули и взяли 60% микросервисов на PHP как доверительный интервал. Если прогнать мутационное тестирование на этом количестве, мы сможем увидеть общую картину и принять решение, продолжать затею или нет.

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

image-loader.svg

Если у вас PHP-проект, и вы захотите попробовать мутационное тестирование, то попробуйте библиотеку Infection. Именно она помогла нам сделать MVP очень быстро.  

Мы собрали данные и получили средний MSI в 64,8%. Это значит, что если у нас есть 100 мутаций, то ~35 из них не будут пойманы. Если сказать в общем, то можно считать эти мутации потенциальными багами. Если говорить в абсолютных цифрах, то в эксперименте участвовало ~10 000 тестов, и из них в ~4000 были обнаружены мутации, которые потенциально могли что-то сломать.

Чтобы понять, с чем имеем дело, мы начали анализировать отчёты о тестировании и выявлять проблемы с нашими тестами. Вот топ-4:

  1. Не проверяются вызовы void-методов. Например отправка различных метрик, которые могут быть критичны для бизнеса. Также это сеттеры объектов, вызовы родительского конструктора в PHP и т.д.

  2. Не хватает проверок на граничные значения.

  3. Не хватает негативных тестов.

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

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

Запуск на мастер-ветках 

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

Процесс получился довольно простой и подозрительно похожий на MVP, только теперь список сервисов был полным и пополняемым. Мы также берём репозитории сервиса, собираем Docker-образ, инжектим библиотеку, запускаем мутационное тестирование, получаем и обрабатываем результаты. Верхнеуровневые результаты храним в базе: там есть MSI, количество мутантов, время начала/окончания тестирования и статус. Расширенные данные отправляем в виде отчётов в специальное хранилище.

image-loader.svg

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

Дело в том, что когда мы пилили MVP, то практически в ручном режиме обеспечивали запуск. Делали всё для запуска конкретных 60% сервисов и ехали дальше. Мы хардкодили, потому что было важно получить результат побыстрее. Этим мы и выстрелили себе в ногу, ведь при реальной работе сервиса неизвестно, какой конкретно сервис попадёт под наши жернова в следующий раз. Мы знаем только язык сервиса, его название и всякие метаданные, но больше никаких особенностей.

Так как сервисов было много и они были разные, мы столкнулись с целый пластом проблем:

  • Ошибки при сборке Docker-образа.

  • Тесты не проходят, а это необходимое условие для работы фреймворка мутационного тестирования.

  • Тайные знания о сервисах. Например, для запуска нужно закомментить такой-то кусок или добавить в конфиг некую переменную.

  • Требуются сборки чего-то дополнительного.

  • Наличие определённых переменных окружения.

Например, тесты иногда не проходили потому, что в проекте были тайные знания различного характера: где-то надо было собрать DI, где-то не хватало словарей. Ещё одна популярная причина: тесты позиционировались, как юнит, а на самом деле были интеграционными, потому что для них нужно было поднять ещё что-то сбоку. 

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

image-loader.svg

Мы двигались постепенно и достаточно плавно. Хочется сказать, что:

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

Подход с детальным анализом и разбором ошибок помог нам словить все грабли в начале и исправить первопричины их появления. После всех доработок success rate вырос с 36% до 98%. 

Запуск на пул-реквестах 

На этот момент мы закрыли два первых требования к сервису для мутационного тестирования:  

  • Научились запускаться без участия команд.

  • Обеспечили актуальность результатов: МТ гоняется снова, когда у мастера меняется commit hash.

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

Пользователь делает pull request. Мы на это триггеримся и запускаем мутационное тестирование на ветке и, по необходимости, на мастере. Внутри дополнительно проверяем, стоит ли вообще запускать мутационное тестирование или нет, какие файлы нужно мутировать и какие тесты при этом запускать. Для этого мы реализовали несколько стратегий, но здесь я привожу упрощённый вариант, когда мы запускаем просто всё.

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

image-loader.svg

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

afbb2f60c9225a9e6fa6d5559ce433a9.jpg

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

5ee5b9d729290447d3a6539a26e40305.jpg

Иногда люди пугались и негодовали, когда бот говорил, что тесты стали не такими эффективными:

b573af896c5b7c266538091c93bb69df.jpg

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

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

Потом мы, конечно же, исправились: провели большое демо перед основным запуском, написали хорошую вводную документацию, сделали отдельный канал для обращений, собираем user voices и всё такое.

Так мы закрыли еще одно требование к нашему сервису — предотвращать проблемы с тестами на ранних этапах.

Инструменты сервиса для разработчиков

Выше я упоминал об инструментах, которые мы сделали для удобства команд. Расскажу о них чуть подробнее. 

Отчёты на пул-реквестах

Отчёт, ссылку на который присылает бот на пул-реквестах, выглядит так:

image-loader.svg

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

Дашборд с сервисами команды 

Также мы сделали дашборд, где каждая команда видит свои сервисы:  

image-loader.svg

Здесь показан текущий MSI для каждого сервиса. Есть информация о том, когда был последний прогон мутационного тестирования и был ли он успешным. Если прогон завершился с ошибкой, мы подсвечиваем причины. В дашборде также есть ссылка на отчёт о тестировании мастер-ветки, куда можно зайти и посмотреть, как обстоят дела с тестами у сервиса в данный момент.

Отчёт о тестировании мастер-ветки

Отчёт похож на тот, что мы присылаем на пул-реквестах:

image-loader.svg

В нём показан текущий MSI, общее количество мутаций в проекте, сколько из них убито, сколько сбежало, дата тестирования и подробности по каждому из мутантов. Мутантов можно сгруппировать по файлам, по типу — как удобнее. Этот отчёт автоматически актуализируется при изменении кода в мастер-ветке сервиса. 

Общая статистика всех сервисов 

Мы пробовали сделать много разных графиков, но в итоге прижился только один:  

image-loader.svg

Пользователь заходит сюда и выставляет нужные ему фильтры, например свою команду, и видит графики по своим микросервисам. Мы определили бейзлайн в 75% MSI и отметили его зелёным. Так нагляднее видно, показатели каких сервисов у тебя вписываются в базовый уровень, а какие — нет. Также на графике можно увидеть как во времени менялся MSI и сделать какие-то выводы: так было, так стало. 

Эти инструменты закрыли два последних требования к сервису: сделать понятные и удобные отчёты и статистику по разным командам. 

Своя библиотека для Go

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

Для PHP всё было классно. Для Go была только одна библиотека — go-mutesting, но это скорее хороший, добротный каркас. Мутаций в библиотеке практически не было. Мы форкнули её и доработали для себя:

  1. Сделали 50+ вариантов мутаций.

  2. Добавили отчёт в json-формате, который можно отрисовать как угодно.

  3. Расширили состав конфигов и сделали возможность сохранить конфигурационный файл.

  4. Добавили возможность скипать тесты и группы тестов и возможность что-то не мутировать, исключать нужные папки.

  5. Обеспечили простое использование библиотеки.

Вы можете использовать наш форк для своих проектов. Вот ссылка на Гитхаб.  

Личный опыт и командные выводы

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

Личные выводы. MSI микросервиса, о котором я рассказывал в начале статьи, изначально оказался равен 61%. Я занялся его повышением. Мне пришлось провести немало рефакторинга: переписать сложные функции на более простые, сделать их тестабельными, убрать повторяющийся код, что-то разнести на отдельные компоненты и так далее. А ещё мутационное тестирование неожиданно заставило меня обновить библиотеки в проекте.

Мутационное тестирование показало, что при достаточно высоком покрытии тестами — 95% — много чего не было протестировано. Причём в этом числе были критичные вещи, важные для бизнес-логики. Мутационное тестирование вынудило меня делать более явные проверки, когда это возможно, и избегать повсеместного использования МОКов. 

После всех этих манипуляций MSI увеличился до 91,5%. И вместе с тестовым покрытием в 95% это стало вызывать доверие. Причём само покрытие осталось прежним, я только делал тесты лучше, писал новые и рефакторил код. Из этого можно сделать вывод, что без MSI тестовое покрытие не очень информативно.

Ещё этот опыт стал для меня хорошим источником роста как инженера. Тогда я только начинал писать на Go и узнал много нового благодаря тому, что покопался с мутантами. Также я понял, что использование мутационного тестирования в работе значительно повышает скилл тестирования у разработчика. Благодаря нему разработчик узнает и начинает применять на практике разные техники тест-дизайна, сам того не осознавая.

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

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

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

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

© Habrahabr.ru