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

Поэтому хочу поделиться с вами опытом внедрения мутационных тестов в проект, рассказать зачем оно нужно и какую ценность несет. Рассмотрим пример внедрения 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

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


  1. Aquahawk
    24.05.2023 12:37

    А можно общую информацию, сколько у вас примерно обычных тестов, в штуках, сколько человек пишет код и тесты, сколько времени (месяцев, лет) вы используете мутационное тестирование?


    1. NikitchenkoSergey Автор
      24.05.2023 12:37

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

      Поэтому сейчас тесты пишутся как часть решения задачи. Тесты стараемся писать на каждый метод API. Обычные тесты пишем очень давно, но серьезный подход сформировался года 3 назад. И когда у команды есть опыт - тесты не только не замедляют разработку, а значительно ее ускоряют за счет минимизации багов и регрессии. А так тесты набросать занимает не более 10% времени от задачи, это окупается даже на мелких проектах.

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


      1. Aquahawk
        24.05.2023 12:37

        под сколько человек я имел ввиду количество коммитеров всего, за последний год например. Просто команды на 3 человека и на 30 совершенно по-разному управляются и требования разные. Я погладываю на мутационное тестирование уже лет 6 наверно, но так и не подвернулось места где хотелось бы включить его в пайплайн. Сейчас в моём проекте под 4 тысячи тестов и под десяток коммитеров, и я точно знаю что включив эту штуку найду тонну вещей которые не стоят и грамма внимания, и совершенно непонятно что с этим делать, установить метрику что процент выживающих мутантов должен, в среднем, падать? Мотивировать команду работать над этим? Точно ли я получу хоть какую-то пользу от того что количество выживающих мутантов упадёт с 90% до 85% например.


        1. NikitchenkoSergey Автор
          24.05.2023 12:37

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


          1. Aquahawk
            24.05.2023 12:37

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


  1. TedDenisenko
    24.05.2023 12:37

    По факту это тесты для тестов

    Объясните джуну, пожалуйста, почему такая вещь не overkill? Наличие самих тестов понятно, они проверяют качество написанного кода, если разработчик где-то не доглядел.
    Но тесты тестов? Т.е., получается, что мы написали тесты, которые тестируют тесты, которые тестируют код. Мне кажется, так можно делать до бесконечности


    1. NikitchenkoSergey Автор
      24.05.2023 12:37
      +2

      Не совсем, внедрение мутационных тестов не требует написания еще тестов. Мутационный фреймворк запускает ваши существующие тесты и ищет в них недостатки, проверяя ими измененный код приложения. Тем самым вы просто улучшаете свои тесты :)


    1. Aquahawk
      24.05.2023 12:37
      +3

      Я конечно не автор топика, но понаписал всякого, включая проприетарный тестовый фреймвёрк и проекты на нём. Всегда можно сделать тест который просто ничего не делает. Как понять, что тест действительно проверяет функционал? Есть подход когда измеряется code coverage, т.е. проверяется какие строки кода были выполнены в ходе тестирования, но из за ошибки, или сознательных действий, тест может некорректно проверять результат или вообще не проверять. Получится что тесты есть, они все зелёные, code coverage 90% и всё казалось бы ок, а на самом деле ничего не проверяется. Мутационное тестирование позволяет убедиться, что тест есть, и что он именно проверяет результат, т.к. если код изменился, а тест всё ещё проходит, значит тест это (то, что изменилось) не проверяет (что кстати тоже не всегда так, могут быть мутации приводящие к эквивалентному коду). А оверкилл это или нет, зависит от ситуации, ответственности и маркетинговой стратегии. Вообще, есть очень простое правило, как определить сколько тестов нужно писать: столько чтобы команда чувствовала себя уверенно для внесения изменений и не боялась. У одних команд это очень много тестов, у других мало, у третьих ноль. В каждом проекте я руководствуюсь этим соображением и всё в порядке уже второй десяток лет.


      1. TedDenisenko
        24.05.2023 12:37

        Спасибо, стало куда понятнее :)


  1. joffer
    24.05.2023 12:37

    какая-то очень странная концепция

    если твои тесты не работают - это не тесты, нужно написать тесты

    если они работают плохо - это плохие тесты, нужно написать хорошие тесты

    Разработчики склонны писать “позитивные” тесты

    ну да, так называемый "идеальный" сценарий, проверять core functionality. Всё равно все кейсы не покрыть, для пользовательского ввода есть валидация.

    И тоже странно, что это начинает работать эффективно, когда условно тестами покрыто больше 70% кода - ничего себе, этом как бы единичные проекты могут похвастаться.

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

    Как вообще это должно работать? Какой реальный сценарий?

    Вот есть условный проект и его кодовая база. Берём и условно покрываем проект на 80% тестами (что само по себе близко к нереальному сценарию). Но, так как у нас нету уверенности в тестах, как могло так получиться? Эти тесты сгенерированы автоматически или как так получается, что тесты написаны, но могут проверять "ничего"?

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

    Как глобально работает сценарий? Мы пишем код проекта, генерируем тесты (чтобы не писать тесты), затем загоняем "мутационный" модуль, от которого часть тестов повалится. И мы их удаляем (?) и генерируем новые? Или пишем руками? Просто если руками - почему сразу не проверять работоспособность тестов?

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

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


    1. NikitchenkoSergey Автор
      24.05.2023 12:37

      Я позволю себе сослаться на хороший комментарий от @Aquahawk выше.

      Как я и писал в статье, мутационное тестирование - это тяжелая артиллерия, и далеко не всем она нужна.

      Не совсем понял, почему вы пишете "генерируем тесты", тесты пишут живые люди пока что (по крайней мере я пока не видел штуку, которая бы по бизнес-требованиям могла создать тест для кода). В этом и есть проблема, что в них тоже могут быть ошибки, они могут не покрывать что-то, или проверять неправильно. Глазами такое почти нереально отловить.

      Да, отчет по выжившим мутантам придется смотреть вручную, и думать, делать с ними что-то или не делать.

      Ну и не соглашусь насчет нереалистичности покрытия в 70%+, для этого чаще всего достаточно писать по тесту на каждую ручку API, чтобы проверяла основной сценарий.


      1. joffer
        24.05.2023 12:37

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

        про "нереалистичность" написал всего лишь в контексте того, что, к сожалению, очень редко встречалось покрытие тестами хотя бы на треть, как правило, тестов или нету, или есть сугубо на "идеальные сценарии" критичного функционала, финрасчёты, вот такое. Да, возможно, я не там работал, с этим и спорить не буду, но пока не видел ни одного проекта, где хотя бы 50% покрытие было

        про "тяжёлую артиллерию" - это понятно, просто сама концепция "мутантов" попалась впервые и попытался её осмыслить с позиции своего опыта и практики, скорее всего если грамотно использовать - возможно, это и профитно.

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

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


        1. NikitchenkoSergey Автор
          24.05.2023 12:37

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

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

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


          1. joffer
            24.05.2023 12:37

            ага, кажись, теперь понял

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

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

            Или это такой себе способ подстраховать квалификацию тех, кто такие тесты пишет? Или тесты пишутся на "идеальные" сценарии? Типа есть проект, 70% функционала которого покрыто тестами на идеальные сценарии - тогда натравить мутационку и выделить заведомо слабые. Это как-то так работает?


            1. NikitchenkoSergey Автор
              24.05.2023 12:37

              Насчет того, почему разработчик не может проверить сам - по той же причине, почему не может написать код без багов :)

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

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


  1. IvanVakhrushev
    24.05.2023 12:37
    +1

    Спасибо за статью.

    Мне не хватило open-source примера на GitHub.

    P.S. Рекомендую посмотреть в сторону Stryker-mutator и их дашбордов по мутационным тестам