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

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

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

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

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

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

Давайте рассмотрим такую ​​ситуацию: у нас есть базовая функция, которая подтверждает, что человек является взрослым, и есть тест для проверки. DataProvider для теста проверит два случая: возраст 17 и возраст 19 лет. Я уверен, что для многих из вас очевидно, что isAdult имеет 100% охват. Одна единственная строка. Он проходит проверку. Все великолепно.

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

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

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

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

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

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

Теперь давайте посмотрим на алгоритм. Это довольно просто. Первое, что мы делаем для реализации мутационного тестирования, - это берем исходный код. Затем мы получаем покрытие кода, чтобы узнать, какие тесты проводить на каких строках. Затем мы просматриваем исходный код и генерируем так называемые «мутанты».

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

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

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

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

Метрики

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

Для начала разберемся с терминологией.

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

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

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

Основная оценка, которую дает нам тестирование мутаций, - это MSI (индикатор оценки мутации), то есть отношение убитых мутантов к общему количеству.

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

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

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

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

Медленная скорость

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

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

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

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

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

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

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

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

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

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

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

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

Давайте рассмотрим классический пример, который это иллюстрирует. У нас есть переменная A, которую мы умножаем на -1 и делим на -1. В целом эти операции дают одинаковый результат. Мы меняем знак A. Соответственно, у нас есть мутация, которая позволяет нам поменять местами два знака. Эта мутация не нарушает логику программирования. И тесты не должны его ловить или терпеть неудачу. Эти мутации могут вызвать определенные осложнения.

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

Это теория. Что в PHP?

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

Но когда я посетил страницу Humbug, он сказал: Этот пакет устарел, попробуйте Infection. Итак, часть моей статьи оказалась бессмысленной. В любом случае - Инфекция - действительно хороший инструмент. Надо поблагодарить Макса Рафалько из Минска, создавшего его. Он проделал большую работу. Можно достать из коробки, пропустить через Composer и запустить.

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

Есть готовый вариант, но в формате PHPUnit это множество файлов XML. Помимо того факта, что они содержат ценную информацию, они приносят массу структур, свойств и тому подобного. Я подсчитал, что обычно покрытие нашего кода занимает около 30 ГБ, и нам нужно распределить его по всем нашим облачным машинам, постоянно читая диск. По сути, это не самая лучшая идея.

А вторая проблема с инфекцией оказывается еще более серьезной. У нас есть отличная библиотека SoftMocks. Это позволяет нам бороться с плохо протестированным устаревшим кодом и писать для него успешные тесты. Мы активно его используем и скоро уже не сможем без него обойтись, несмотря на то, что наш новый код написан так, чтобы исключить необходимость в SoftMocks. Итак, эта библиотека несовместима с Infection, потому что они обе используют почти одинаковые подходы к мутированным изменениям.

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

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

Как это использовать?

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

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

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

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

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

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

Например, у меня были проблемы с двумя файлами, и я получил такой результат. У меня в мастере было 548 мутантов, из которых 400 убито. В другом файле - 147 против 63. В обоих случаях количество мутантов в моих ветвях увеличилось. Но в первом файле мутант был убит, а во втором сбежал. Конечно, оценка MSI упала. Это дает возможность даже тем, кто не хочет тратить время на тестирование мутаций вручную, увидеть, что они сделали хуже, и уделить этому немного внимания (как это делают рецензенты в процессе проверки кода).

Полученные результаты

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

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

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

Выводы

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

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

Начните тестирование мутаций, даже если оно выполняется вручную. Просто сделайте первый шаг и посмотрите, что он вам даст. Я уверен, тебе понравится.