Хабрахабр

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Метрики 

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

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

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

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

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

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

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

В этом случае мы рассчитываем MSI только для тех мутантов, которые были покрыты тестами.  И последний показатель — это covered 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. Так вот, эта библиотека несовместима с Infection, потому что они используют практически одинаковый подход к мутированию изменений.  Мы ею активно пользуемся и не собираемся от неё отказываться в ближайшее время, несмотря на то, что новый код пишется у нас так, что SoftMocks нам не требуется.

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

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

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

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

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

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

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

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

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

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

Результаты

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

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

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

Выводы 

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

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

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть