company_banner

Мутационное тестирование в PHP: качественное измерение для code coverage

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

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

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



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


    Чтобы объяснить, что я имею в виду, покажу пару примеров. Они простые, местами утрированные и могут казаться очевидными (хотя реальные примеры обычно довольно сложные и глазами их не увидеть). 

    Рассмотрим ситуацию: у нас есть элементарная функция, которая утверждает, что некий человек совершеннолетний, и есть тест, который её проверяет. У теста есть dataProvider, то есть он тестирует два случая: возраст 17 лет и возраст 19 лет. Я думаю, для многих из вас очевидно, что isAdult имеет 100%-ное покрытие. Единственная строчка. Тестом она выполняется. Всё замечательно.



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

    Ещё один пример, чуть более сложный. Есть функция, которая строит некий простой объект, содержащий сеттеры и геттеры. У нас есть три поля, которые мы устанавливаем, и есть тест, который проверяет, что функция buildPromoBlock действительно собирает тот объект, который мы ожидаем.



    Если посмотреть внимательнее, у нас также есть setSomething, который ставит какое-то свойство в true. Но в тесте у нас такого ассерта нет. То есть мы можем эту строчку удалить из buildPromoBlock — и наш тест это изменение не поймает. При этом мы имеем 100%-ное покрытие в функции buildPromoBlock, потому что все три строчки были исполнены во время теста. 

    Эти два примера подводят нас к тому, что такое мутационное тестирование. 

    Прежде чем разбирать алгоритм, дам короткое определение. Мутационное тестирование — это механизм, который позволяет нам, внося мелкие изменения в код, имитировать действия злобного Буратино или джуниора Васи, который пришёл и начал целенаправленно его ломать, знаки > заменять на <, = на !=, и так далее. Для каждого такого изменения, сделанного нами в благих целях, мы прогоняем тесты, которые должны покрывать изменённую строку. 

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

    Теперь давайте разберём алгоритм. Он довольно простой. Первое, что мы делаем для осуществления мутационного тестирования, — берём исходный код. Дальше мы получаем code coverage, чтобы знать, какие тесты выполнять для какой строки. После этого мы пробегаемся по исходному коду и генерируем так называемых мутантов. 

    Мутант — это единичное изменение кода. То есть мы берём некую функцию, где был знак > в сравнении, в if, меняем этот знак на >= — и получаем мутанта. После этого мы прогоняем тесты. Вот пример мутации (мы заменили > на >=): 



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

    Последнее, что мы делаем, — прогоняем тесты, которые покрывают мутировавшую строчку. Вытаскиваем это из coverage. Есть инструменты неоптимальные, которые гоняют все тесты. Но хороший инструмент будет прогонять только те, которые необходимо. 

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

    Метрики 


    Какие метрики даёт нам мутационное тестирование? К code coverage оно добавляет ещё три, о которых мы сейчас поговорим. 

    Но для начала разберём терминологию. 



    Есть понятие убитых мутантов: это те мутанты, которых «прибили» наши тесты (то есть они их отловили). 



    Есть понятие escaped mutant (выживших мутантов). Это те мутанты, которым удалось избежать кары (то есть тесты их не поймали). 



    И есть понятия covered mutant — мутант, покрытый тестами, и обратный ему uncovered мутант, который вообще никаким тестом не покрыт (т.е. у нас есть код, в нём есть бизнес-логика, мы можем её менять, но ни один тест эти изменения не проверяет). 

    Основной показатель, который нам даёт мутационное тестирование, — MSI (mutation score indicator), отношение количества убитых мутантов к их общему количеству. 

    Второй показатель — это mutation code coverage. Он как раз является качественным, а не количественным, потому что показывает, какой объём бизнес-логики, которую можно ломать и делать это на регулярной основе, наши тесты отлавливают. 

    И последний показатель — это covered MSI, то есть более мягкий MSI. В этом случае мы рассчитываем MSI только для тех мутантов, которые были покрыты тестами. 

    Проблемы с мутационным тестированием


    Почему меньше половины программистов слышали об этом инструменте? Почему его не применяют повсеместно?

    Низкая скорость 


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

    Что можно сделать, чтобы это нивелировать? Запускайте тесты параллельно, в несколько потоков. Раскидывайте потоки на несколько машин. Это работает. 

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

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

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

    Бесконечные мутанты


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



    Если заменить i++ на i--, то цикл превратится в бесконечный. Ваш код залипнет надолго. И мутационное тестирование довольно часто генерирует такие мутации. 

    Первое, что можно сделать, — тюнинг мутации. Очевидно, что менять i++ на i-- в цикле for — это очень плохая идея: в 99% случаев мы будем попадать на бесконечный цикл. Поэтому мы в своём инструменте такое делать запретили. 

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

    Идентичные мутанты


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

    Рассмотрим классический пример, её иллюстрирующий. У нас есть умножение переменной А на -1 и деление А на -1. В общем случае эти операции приводят к одинаковому результату. Мы меняем знак у А. Соответственно, у нас есть мутация, которая позволяет два знака менять между собой. Логика программы такой мутацией не нарушается. Тесты и не должны ее ловить, не должны падать. Из-за таких идентичных мутантов возникают некоторые сложности. 

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

    Это теория. А что в PHP? 


    Есть два известных инструмента для мутационного тестирования: Humbug и Infection. Когда я готовил статью, хотел рассказать о том, какой из них лучше, и прийти к выводу, что это Infection. 
    Но когда я зашёл на страницу Humbug, то увидел там следующее: Humbug объявил себя устаревшим в пользу Infection. Поэтому часть моей статьи оказалась бессмысленной. Так что Infection — действительно хороший инструмент. Надо сказать спасибо borNfree из Минска, который его создал. Он действительно круто работает. Можно взять его прямо из коробки, через композер поставить и запустить. 

    Нам действительно понравился Infection. Мы хотели его использовать. Но не смогли по двум причинам. Infection требует code coverage, чтобы правильно и точечно запускать тесты для мутантов. Тут у нас есть два пути. Мы можем посчитать его прямо в рантайме (но у нас 100 000 юнит-тестов). Либо мы можем посчитать его для текущего мастера (но сборка на нашем облаке из десяти очень мощных машин в несколько потоков занимает полтора часа). Если мы будем на каждом мутационном прогоне этим заниматься, наверное, инструмент работать не будет. 

    Есть вариант скормить готовый, но в формате PHPUnit это куча XML-файлов. Помимо того, что там содержится ценная информация, они тащат за собой кучу структуры, каких-то скобок и прочего. Я посчитал, что в общем случае наш code coverage будет весить порядка 30 Гб, а нам нужно таскать его по всем машинам облака, постоянно читать с диска. В общем, затея так себе. 
    Вторая проблема оказалась ещё более существенной. У нас есть замечательная библиотека SoftMocks. Она позволяет нам бороться с легаси-кодом, который сложно тестировать, и успешно писать для него тесты. Мы ею активно пользуемся и не собираемся от неё отказываться в ближайшее время, несмотря на то, что новый код пишется у нас так, что SoftMocks нам не требуется. Так вот, эта библиотека несовместима с Infection, потому что они используют практически одинаковый подход к мутированию изменений. 

    Каким образом работают SoftMocks? Они перехватывают инклуды файлов и подменяют их модифицированными, то есть вместо того чтобы исполнить класс А, SoftMocks создают класс А в другом месте и вместо исходного через инклуд подключают другой. Infection действует точно так же, только он работает через stream_wrapper_register(), который делает то же самое, но на системном уровне. В результате у нас могут работать либо SoftMocks, либо Infection. Так как для наших тестов SoftMocks необходимы, то подружить эти два инструмента очень сложно. Это, наверное, возможно, но в этом случае мы так сильно влезем в Infection, что смысл от таких изменений просто теряется. 

    Превозмогая трудности, мы написали свой маленький инструмент. Мы позаимствовали мутационные операторы у Infection (они классно написаны и ими очень легко пользоваться). Вместо того чтобы запускать мутации через stream_wrapper_register(), мы запускаем их через SoftMocks, то есть из коробки используем наш инструмент. Наша тулза дружит с нашим внутренним сервисом code coverage. То есть она по требованию может получать coverage для файла или для строки без прогона всех тестов, что происходит очень быстро. При этом она простая. Если у Infection есть куча всяких инструментов и возможностей (например, запуск в несколько потоков), то в нашей ничего такого нет. Но мы используем нашу внутреннюю инфраструктуру, чтобы нивелировать этот недостаток. Например, тот же запуск тестов в несколько потоков мы осуществляем через наше облако. 

    Как мы это используем? 

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



    Я запустил мутационный тест для какого-то файлика. Получил результат: 16 мутантов. Из них 15 были убиты тестами, и один упал с ошибкой. Я не сказал о том, что мутации могут сгенерировать фатал. Мы легко можем что-то изменить: сделать так, чтобы возвращаемый тип стал невалиден, или ещё что-то. Такое возможно, это считается убитым мутантом, потому что наш тест начнёт падать. 

    Тем не менее Infection выделяет таких мутантов в отдельную категорию по той причине, что иногда на ошибки стоит обратить особое внимание. Бывает, что происходит что-то странное — и мутант не совсем правильно считается убитым. 

    Вторая штука, которую мы используем, — это отчёт по мастеру. Раз в сутки, ночью, когда наша девельная инфраструктура простаивает, мы генерируем отчёт по code coverage. После этого мы делаем такой же отчёт по мутационному тестированию. Выглядит это так:



    Если вы хоть раз смотрели в отчёт по code coverage PHPUnit, то наверняка заметили, что интерфейс похожий, потому что мы сделали свой инструмент по аналогии. Он просто для какого-то конкретного файлика в какой-то директории посчитал все ключевые показатели. Мы также заложили определённые цели (на самом деле, мы взяли их с потолка и пока не соблюдаем, так как ещё не решили, на какие цели стоит ориентироваться по каждой метрике, но они существуют, чтобы в будущем можно было легко строить отчёты). 

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



    Например, я запушил два файла и получил такой результат. В мастере у меня было 548 мутантов, убито 400. По другому файлу — 147 против 63. В моей ветке количество мутантов в обоих случаях прибавилось. Но в первом файле мутант был прибит, а во втором — он сбежал. Естественно, показатель MSI упал. Такая штука позволяет даже людям, которые не хотят тратить время, запускать мутационное тестирование руками, видеть, что они сделали хуже, и обращать на это внимание (ровно так же, как это делают ревьюеры в процессе code review). 

    Результаты


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

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

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

    Выводы 


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

    Мутационное тестирование поможет сделать ваши юнит-тесты лучше, а отслеживание code coverage — осмысленнее. Для PHP уже есть инструмент, так что если у вас небольшой проект без заморочек, то прямо сегодня берите и пробуйте. 

    Начните хотя бы с прогона мутационных тестов вручную. Сделайте этот простой шаг и посмотрите, что вам это даст. Уверен, что вам понравится. 
    Badoo
    389,22
    Big Dating
    Поделиться публикацией

    Комментарии 27

      +3
      Много ли поймали реальных кейсов, когда мутационное тестирование реально нашло баг, который ускользал от обычного теста?
        +4
        Много забавного поймали :)

        Например, опечатки в assert-ах:
        assertCount(1, count($some_var)); //до php 7.2 спокойненько себе всегда проходила
        assertTrue(true, $some_var);
        

        Еще находились кусочки кода, в тестах для которых вообще не покрыты граничные условия, при этом code coverage близился к 100%.
        Была даже пара тестов которые при любом изменении тестируемого кода проходили ¯\_(ツ)_/¯

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

        Но главное — мутационное тестирование поможем вам начать писать более качественные тесты прям со старта.
          +4
          Еще находились кусочки кода, в тестах для которых вообще не покрыты граничные условия, при этом code coverage близился к 100%.

          Кстати хороший пример, почему даже серьезное покрытие тестами ещё ничего не гарантирует :).
      • НЛО прилетело и опубликовало эту надпись здесь
          0
          Честно сказать, не представляю как давать такие подсказки :)
          Это ресурсоемко, но не неподъемно как кажется на первый взгляд, попробуйте.
          • НЛО прилетело и опубликовало эту надпись здесь
          –2

          А вам (автору или команде) не кажется что ваши юнит тестьі не в порядке? 10 минут? Что там тестируется?


          Может стоит отказаться от моков в пользу стабов и фикстур? Как минимум по возможности меньше использовать.

            +1

            Я больше чем уверен, что эти цифры приведены в качестве примера. Даже когда я ещё работал в Badoo (около 3 лет назад) было около 60 тысяч юнит тестов и они шли порядка 100 минут, если посчитать суммарно время выполнения всех из них (но при этом они выполнялись параллельно и проходили за пару минут). На первый взгляд может показаться, что там действительно нечего тестировать, но на самом деле вы видите только вершину айсберга и на деле кода в Badoo очень много и его нужно тестировать :).

              0

              Вьі меня совершенно не правильно поняли. Вопрос не в коде. Вопрос к тестам. Тест на 100мс слишком тяжел. Даже 10мс много для одного юнит теста.

                +1
                Вы безусловно правы, в идеальных условиях именно столько должны бежать юниты. Но проект огромный, много тестов писалось на заре развития юнит тестирования, не самым оптимальным способом. Все это приводится в порядок, новый код пишется так чтобы тесты для него были быстрые и блестящие, но это процесс не быстрый.
                  +1
                  Вы не пробовали ввести метрику по скорости? Например «процент тестов, исполняющихся дольше 10мс».

                  И почему компания относится к тестам как к артефактам? «Легаси» тесты не несут компании тех преимуществ, как «легаси код». Их имеет смысл обновлять для легаси кода. Учитывая что большая часть времени в написании тестов тратится не на само написание теста, а написания фреймворка под приложения — скорее всего у вас уже есть фишки которые можно быстро внести в старые тесты.

                  Я понимаю что звучу как мудак с наездами. Но это топ моей интровертной социальности. Мне действительно интересна дискусия. Куда развивается QA когда на него выделяют ресурсы. Почему решение Х (распаралеливание) лучше Y (постепенный рефактор тестовой базы)
                    +1
                    Есть метрика, есть отчеты в разрезах команд, компонентов и прочего :)

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

                    Не-не, дискуссии это прекрасно. Никто и не говорит что решение X лучше Y. X — способ существовать сейчас, Y — светлое будущее достижение которого требует времени. К тому же, решение Х еще позволяет запускать функциональные и end-to-end тесты, которые нельзя сделать сильно быстрыми.

                      0
                      А какие у вас требования к новым тестам?
            +1
            Как уже написал youROCK выше, это всего лишь пример взятый из головы.

            В Badoo по состоянию на сегодня 71 000 тестов (полгода назад было чуть больше 100 000), в результате оптимизаций и пересмотра мы немного уменьшили их количество, но сильно подтянули по скорости работы. Сейчас они пробегаю за 35-40 секунд на нашем тестовом облаке.
              0
              Скорость действительно впечатляет. Это только unit тесты, или среди них есть еще и функциональные, тесты, работающие с БД, http запросами? Расскажите по-подробней, пожалуйста.

              И каковы основные приемы ускорения, кроме параллелизации?
                +5
                Это только Unit-тесты.
                Есть еще 19 000 функциональных, на облаке сейчас пробегают за 3-5 минут, но мы еще не брались за их оптимизации.
                И примерно 6 300 end-to-end тестов, бегают около 10 минут сейчас, активно оптимизируем.

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

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

                Второе, максимально облегчили изначально тяжелый базовый тест-кейс. Например исторически мы на tearDown чистили большой список классов со статик кэшами и синглтонами (которые еще остались со времен когда это было модно :)). Мы пересмотрели этот подход, перестали их чистить на каждый чих и просто наладили мониторинг за тестами — если тест оставляет за собой грязный контекст — мы это быстро обнаруживаем.
                  +2
                  Я не думаю, что основная архитектура параллельного запуска не особо изменилась. Я о ней писал статью: m.habr.com/ru/company/badoo/blog/220211
                  0

                  Вот 40 секунд для 100 000 гораздо лучше звучит.


                  Но тогда вопрос. Мутационньіе тестьі добавляют 3 порядка по времени исполнения? С полуминутьі на часьі?

                    +1
                    Все так. Около 2 часов полный прогон занимает. Поэтому мы считаем этот отчет ночью.
                    А в течении дня считаем только для branch diff и сравниваем с мастером (который рассчитали ночью)
                      0
                      Вы не изучали откуда такое различие? Из-за слишком большого проекта, то есть на строчку кода приходится 1000 тестов? Создатели мутационных тестов используют медленные решения? Как пример — может быть рефлексия будет быстрее загрузки измененного файла.

                      • НЛО прилетело и опубликовало эту надпись здесь
                • НЛО прилетело и опубликовало эту надпись здесь
                  • НЛО прилетело и опубликовало эту надпись здесь
                      0
                      спасибо за статью.
                      • НЛО прилетело и опубликовало эту надпись здесь
                        +2
                        1. Есть ли у вас новые какие-то мутаторы по сравнению с Infection? Если да, не хотите ли вы контрибьютнуть обратно?
                        2. Забираете ли вы к себе наши новые мутаторы, когда они добавляются в Infection?

                        Мы думаем о выделении набора мутаторов в отдельный package, что бы можно было их проще использовать всем, кому захочется, и что бы было возможно всем (а не только нам) их и саппортить / обновлять.
                          0
                          1. Пока ничего нового не придумали :) Но если придумаем — обязательно законтрибьютим!
                          2. Я обновлял их только один раз, в текущем исполнении это немножко нудно, а текущий набор мутаторов нас устраивает

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

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

                        Самое читаемое