Pull to refresh

Лови мутанта! Мутационные тесты: зачем и как

Level of difficultyMedium
Reading time5 min
Views3.9K

Я очень люблю тесты и считаю, что любой код должен быть покрыт ими, желательно качественными :)  

Поэтому хочу поделиться с вами опытом внедрения мутационных тестов в проект, рассказать зачем оно нужно и какую ценность несет. Рассмотрим пример внедрения Infection в приложение на Laravel. Но сначала немного теории.

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

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

Работает это так: мутационный фреймворк изменяет исходный код проекта согласно определенному набору правил, например, меняет “===” на “!==” (на самом деле там большой список мутаций) и для каждого такого изменения (мутанта) прогоняет тесты. Если тесты упали, значит мутант считается убитым, если тесты не заметили изменений - мутант выжил и на него стоит посмотреть, скорее всего тест нужно доработать.

Кроме того, сразу будет видно, какие мутанты вовсе не покрыты тестами, они будут пропущены фреймворком.

Зачем их внедрять?

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

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

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

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

Что для этого нужно?

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

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

Мутационные тесты - это уже тяжелая артиллерия. В первую очередь рекомендую добиться хотя бы 70% покрытия тестами проекта/модуля.

Посмотреть покрытие в Laravel можно так:

php artisan test --parallel --coverage

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

Как писать тесты?

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

Лично я в первую очередь рекомендую писать функциональные тесты (тестирование черным ящиком ручек API) по нескольким причинам.

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

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

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

Ну и конечно тесты должны запускаться в CI после каждого коммита, иначе смысла в них нет.

От теории к практике

Посмотрим, как это все выглядит на Laravel приложении.

Сначала нужно установить мутационный фреймворк, там все очень просто: https://infection.github.io/guide/installation.html 

При первом запуске infection, он спросит, какие папки будем анализировать и создаст конфиг с ними.
Я рекомендую туда добавить пару вещей: timeout и logs.

{
   "$schema": "https://raw.githubusercontent.com/infection/infection/0.27.0/resources/schema.json",
   "source": {
       "directories": [
           "app"
       ]
   },
   "timeout": 50,
   "logs": {
       "html": "infection.html",
   },
   "mutators": {
       "@default": true
   }
}

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

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

Оптимизация

Я думаю, вы уже поняли, что проход тестов по тысячам мутантов дело не быстрое :) 

Но нам на помощь приходит распараллеливание. В первую очередь нам нужно научиться просто запускать тесты в многопоточном режиме, просто добавив флаг --parallel (число потоков будет равно число CPU, можно контролировать флагом --processes):

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

Ну и по аналогии в несколько потоков можно запускать мутационные тесты параметром --threads=:

Что дальше?

Когда у вас сгенерировался первый отчет, не надо пугаться, что 90% мутантов выживает. Тут работает правило 20/80, вы быстро сможете прикрыть незакрытые куски и найти ошибки в тестах. Да и часть мутантов можно игнорировать, например из-за особенностей Laravel проходит мутант, изменяющий видимость экшена на protected, это конечно править не нужно.

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

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

Выводы

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

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

И чем ответственнее проект, тем важнее наличие качественных тестов. В моем случае это парольный менеджер BearPass[ссылка удалена модератором], тут уж точно без хороших тестов никуда.

И прошу - пишите тесты, код без тестов сразу становится легаси!

Сергей Никитченко, SVK.Digital

Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
Total votes 11: ↑10 and ↓1+9
Comments16

Articles