PHPUnit 13.3 выявит нестабильные тесты и покажет покрытие кода через призму классов
PHPUnit на данный момент является де-факто стандартом для написания тестов в экосистеме PHP. Он относится к семейству фреймворков xUnit (как JUnit или NUnit) и предоставляет инструменты для написания автоматизированных тестов.
В PHPUnit 13.3 развитие пошло по двум ключевым направлениям. Первое — улучшение работы с нестабильными тестами, второе — появление нового представления пространств имен и классов, а также еще несколько приятных бонусов в HTML-отчете. Поговорим о каждом направлении более подробно.
Обнаружение нестабильных тестов
Тест, который то падает, то проходит, даже когда покрываемый код не менялся, вызывает не самые приятные чувства. Причин такого поведения может быть множество: от общего изменяемого состояния до недетерминированного порядка выполнения. Самый надежный способ выявить такие тесты оказался до смешного прост: запустить один и тот же тест много раз подряд.
Вплоть до PHPUnit 9 существовала такая опция как --repeat. Однако она повторяла весь набор тестов целиком, что делало ее более подходящей скорее для бенчмаркинга. Именно поэтому опцию сначала убрали, но затем из-за большого количества просьб все же вернули с одним «но»: теперь она повторяла не весь набор, а каждый отдельный тест.
Все новое — это хорошо забытое старое. Кажется, этой мудростью вдохновлялись разработчики. PHPUnit 13.3 берет уже готовую опцию и с помощью нее запускает конкретный тест до ста раз подряд через --repeat 100. Если какое-то из повторений заканчивается неудачей, оставшиеся повторения пропускаются. Каждое такое повторение выполняется как самостоятельный тест и идентифицируется в выводе как (repetition N of M). Это помогает выявить и проблемы с утечками в коде, который управляет соединениями или кэшем, когда первые запуски проходят успешно, а проблемы появляются только при множественных вызовах.
С помощью атрибута #[Repeat] можно отметить отдельные тестовые методы. Он смоделирован по образцу аннотации @RepeatedTest из JUnit 5. Конструкцию #[Repeat] можно настраивать. Например, #[Repeat(100,5)] будет продолжать повторения, пока не будет зафиксировано 5 ошибок. Это полезно, если вас интересует именно паттерн отказов: как часто, в каких случаях, на каких повторениях падает тест. Важно отметить, что такой порог не делает ошибки допустимыми — каждое неудачное повторение регистрируется и приводит к провалу теста.
А что делать с тестами, которые нельзя повторять? Возвращаемое значение тестового метода можно передать другим тестам через #[Depends], и его повторение привело бы к созданию нескольких, потенциально разных возвращаемых значений. Поэтому повторяются только те методы, которые явно объявляют void в качестве возвращаемого типа и не зависят от других тестов. При использовании --repeat все остальные тесты выполняются ровно один раз, как и до этого. Однако если атрибут #[Repeat] установлен для метода, который не соответствует этим условиям, то PHPUnit выдаст предупреждение.
Допустимость нестабильных тестов
Некоторые из тестов взаимодействуют с сетевыми сервисами, оборудованием или процессами, тайминги которых они не могут контролировать. В таком случае однократное падение теста ни о чем не скажет, а вот многократные — вполне. До сих пор перезапуск всей задачи в CI часто был единственным выходом, но это дорогое решение, которое к тому же не давало понять, какой именно тест вызвал проблему.
PHPUnit 13.3 предлагает решение лучше. Тест с атрибутом #[Retry(3)] запускается до трех раз. Первая попытка, которая не завершилась ни провалом (failure), ни ошибкой (error) определяет результат. Пропущенная или незавершенная попытка точно так же завершает цикл. В соответствии с гарантией PHPUnit, которая звучит как «один экземпляр на каждый тест», каждая такая попытка запускается на свежем экземпляре тестового класса. C опцией --retry это поведение применяется ко всем подходящим тестам на тех же условиях. Важно понимать, что совместное использование --repeat и --retry приведет к предупреждению, как и в случае с #[Retry] и #[Repeat] для одного и того же метода.
Можно было бы возразить против такого решения: встроенные повторы фактически легализуют нестабильные тесты, а возникающее состояние гонки будет вечно проходить CI и никогда не будет исправлено. PHPUnit отвечает весьма прозрачно: каждый тест, который прошел только после одной или нескольких неудачных попыток перечисляется в сводке вместе с количеством неудачных попыток. Тест, исчерпавший все попытки, падает как любой другой, а тест, прошедший с первой попытки, нигде не выводит информацию, связанную с повторами.
Покрытие кода через призму ваших классов
До сих пор HTML-отчет о покрытии кода показывал ваш код в том виде, в котором он хранится на диске, то есть в виде файлов и каталогов. Но это не очень удобно — разработчик мыслит, как правило, классами, пространствами имен и методами.
Поэтому в отчет добавили классово-ориентированное представление, где каждая страница теперь содержит две вкладки: с файлами и классами. Для первой ничего не поменялось, а вот вторая организует те же данные, но, что логично, по пространствам имен и классам. Эти два представления связаны между собой: со страницы исходного файла вкладка классов переведет вас прямо на страницу объявленного в нем класса, и наоборот.
Каждый класс получает свою собственную страницу со сводной таблицей метрик, включая индекс CRAP по методам, и аннотированным исходным кодом. Отображаются только те строки, которые принадлежат классу, а также дополнительно добавлены отдельные разделы для каждого используемого трейта и метода, унаследованного от родительских классов. Таким образом, страница отвечает на вопрос: «Насколько хорошо протестирован этот класс?».
Важно отметить, что эти два представления намеренно агрегируют данные по-разному: в классо-ориентированном представлении код трейтов и родительских классов засчитывается в пользу просматриваемого класса, поэтому итоговые показатели могут отличаться от показателей файлового представления. Кроме того, такое представление генерируется по умолчанию и примерно удваивает размер отчета. Если нет необходимости в таком формате, его можно отключить через опцию --without-class-view.
Визуализация покрытия ветвей и путей
Теперь представление покрытия ветвей визуализирует точки принятия решений в коде. В новой колонке на полях, для каждой строки где происходит ветвление потока управления, отображается по одной точке на каждый возможный исход: цветом покрытого кода, если тест выполнил этот исход, и цветом непокрытого кода в противном случае.
Это позволяет с первого взгляда заметить if, чей блок else никогда не выполняется, даже если сама строка кажется покрытой. Вместо повторения исходного кода каждой ветви теперь появилась компактная таблица по методам (по одной строке на каждую ветвь) и значок вроде 3/4 в заголовке каждого метода.
Представление покрытия путей было переработано аналогичным образом и дополнительно получило граф потока управления для каждого метода. Он отображает ветви метода в виде узлов, соединенных стрелками от входа до выхода, и раскрашенных в зависимости от покрытия. При нажатии на строку в таблице путей соответствующий путь подсвечивается на графе, что точно показывает, какой именно маршрут через метод описывает данный путь. Для рендеринга графов требуется утилита dot из Graphviz. Если она недоступна, отчет генерируется без графов. Ранее методы с более чем 100 путями вообще не отображались — теперь их первые 100 путей показаны в свернутом разделе вместе с примечанием, указывающим общее их количество.
Фильтрация покрытия кода по размеру тестов
С помощью атрибутов #[Small], #[Medium] и #[Large] вы теперь можете объявлять размер ваших тестов. PHPUnit всегда записывал эту информацию в данные о покрытии кода, но до сих пор HTML-отчет использовал её только для раскрашивания покрытых строк. Теперь для удобства на каждой странице каталога и файла отображается группа кнопок: Small, Medium, Large и All.
При выборе одного или нескольких размеров все цифры на странице пересчитываются: меняются полоски графиков, проценты и счетчики строк, методов и классов. Класс может показывать стопроцентное покрытие в целом, даже если лишь малая его часть покрывается маленькими тестами, а остальное достигается только за счет больших end-to-end тестов. Один клик по Small покажет вам, что покрывают исключительно ваши юнит-тесты. При активном фильтре метод считается протестированным только тогда, когда тесты выбранных размеров полностью покрывают его самостоятельно, а класс считается протестированным, только если это справедливо для всех его методов.
Есть также небольшой нюанс: All и комбинация Small, Medium, Large — это не одно и то же. All полностью игнорирует размеры тестов и учитывает каждую покрытую строку, а комбинация из трех размеров учитывает только те строки, которые покрыты хотя бы одним тестом с объявленным размером. Разница между ними показывает, какая часть покрытия обеспечивается тестами, у которых вообще не указан размер.
Вывод
Из нововведений понятно, что PHPUnit 13.3 эволюционирует в действительно полезный аналитический инструмент, чтобы помочь командам поддерживать порядок в крупных проектах с запутанной историей. Это позволит уменьшить неприятные ощущения от ложных срабатываний в CI/CD, а также давать более корректную оценку качества кода.