
Зачем нужны covers метки? Почему их так хочется сломать? Как это сделать? Рассказ будет извилист, но идея проста: улучшить навигацию, не задев покрытие.
Начнем издалека, зато сразу с примера.
Есть контроллер:
final class GreetingController extends AbstractController { public function __construct(private FunMessage $funMessage){} #[Route('/hello')] public function hello(): Response { $result = $this->generate("Hello!"); return new Response($result); } #[Route('/bye')] public function bye(): Response { $result = $this->generate("Bye!"); return new Response($result); } private function generate(string $prefix): string { return $prefix . ' ' . $this->funMessage->random(); } }
И тест:
class GreetingControllerTest extends WebTestCase { public function test_hello(): void { $content = get('/hello'); assert_starts_with('Hello!', $content); } public function test_bye(): void { $content = get('/bye'); assert_starts_with('Bye!', $content); } }
И тут бизнес скупают испанцы! Теперь вместо "Hello!" используется "¡Hola!".

Тест провалился. Ура!
Заходим в него, смотрим, переходим к коду приложения, сверяем логику, фиксим.
Конец.
Хм.. а о чем я хотел рассказать? Ах да! На практике переход от теста к коду может быть не таким драйвовым занятием:
выделить часть имени тестового класса,
открыть поиск,
ввести "s HelloController" (или какой у вас лайфхак для быстрого поиска), иногда еще учесть namespace,
проскролить до нужного метода..
И так раз за разом.. Зацепило? Если нет, то дальше будет неинтересно.
Может, просто не тестировать контроллеры?
Если тестировать сервис напрямую - навигация куда проще.
И чем тоньше контроллер, тем сильнее соблазн его не трогать. А еще они медленные..
Но чем больше таких тестов, тем лучше сон! А способ ускорить прогон всегда можно найти, было бы желание (распараллелить, предзаполнить БД, задействовать опкеш, оптимизировать само приложение, подкинуть мощностей).
Итак, как связать тест с кодом, если прямого вызова нет?
Вариант 1. Нативный синтаксис
- get('/hello'); + request(GreetingController::hello(...));
Здесь метод request сам найдет нужный путь через рефлексию.
Нравится? Не вздумайте говорить "да".
Да, GreetingController::hello похож на типизацию, а '/hello' - на магическое значение и дублирование в одном флаконе.
Но у теста должна быть независимая копия контракта. Если брать из кода - жди беды!
Вариант 2. Старая добрая аннотация @see
class GreetingControllerTest extends WebTestCase { /** * @see \App\Controller\GreetingController::hello() */ public function test_hello(): void {...} /** * @see \App\Controller\GreetingController::bye() */ public function test_bye(): void {...}
Недостатки:
чтобы указать метод, нужно указать класс (а без
useеще и с полным путем);это все-таки аннотации, которыми нас слишком долго кормили;
это универсальный тег, а значит в IDE для него не появится каких-то особенностей.
Вариант 3. Специализированные атрибуты/аннотации covers
Сначала рассмотрим версию с аннотациями (в phpunit 12 их удалили)
/** * @coversDefaultClass \App\Controller\GreetingController */ class GreetingControllerTest extends WebTestCase { /** * @covers ::hello */ public function test_hello(): void {...} /** * @covers ::bye */ public function test_bye(): void {...}
Чудной синтаксис.
Но автодополнение, переходы, обнаружение использований - работают прекрасно (phpstorm)! Плюсом, можно настроить шаблоны так, чтобы метки сразу появлялись при генерации теста (это когда нажимаешь Ctrl+B по классу или методу и выбираешь Create new PHP Test).

<?php #parse("PHP File Header.php") #if (${NAMESPACE}) namespace ${NAMESPACE}; #end use App\Tests\Base\WebTestCase; /** * @coversDefaultClass \\${TESTED_NAMESPACE}\\${TESTED_NAME} */ class ${NAME} extends WebTestCase { }


/** * @covers ::${NAME} */ public function test${CAPITALIZED_NAME}(): void { }
Шаблоны тестов в phpstorm - боль с древних времен
Шаблоны для тестов - очень похожи на обычные шаблоны. Но изначально их криво отпочковали, и так и оставили.
Проблема 1.
Можно использовать только 2 (два) шаблона для phpunit.
Никто не мешает создать новый (даже клонированием). Но во время генерации он не появятся в списке для выбора. Это особенно неудобно, когда для разных типов тестов свои базовые классы, наборы трейтов и прочие сетапы.
Проблема 2.
Названия шаблонов в списке для выбора при генерации и в списке для редактирования в настройках - неудачные и приводят к путанице. Посудите сами:
PHPUnit - использует шаблон PHPUnit 6
PHPUnit < 6 - использует шаблон PHPUnit
Проблема 3.
Хотя видимых изменений в этой части редактора не происходит, в некоторых версиях phpstorm генератор тестов отваливается. Вместо именованного класса предлагает просто Test. А в списке доступных методов - пустота.
Если столкнусь с этим на свежем проекте - возможно что-то не так настроено (особенно если нестандартное расположение файлов или .idea). Но если раньше все работало - то даунгрейд или апргрейд могут помочь (релоуд данных и переиндексация - нет).
Версия с атрибутами
Плавного перехода от аннотаций к атрибутам не получится, хоть их и наклепали столько, что хоть жопой жуй (классы, методы, функции, трейты, ... https://docs.phpunit.de/en/12.4/attributes.html#code-coverage).
Но проблема в том, что эти атрибуты нельзя прикрепить к методам, только к классам. Это создает сложности как при миграции существующих тестов, так и при генерации через IDE (об этом чуть позже).
Автор phpunit просто закрыл связанное с этим issue, не ответив ни на один последовавший за этим вопрос :(
https://github.com/sebastianbergmann/phpunit/issues/5837
To me, allowing attributes for code coverage targeting to also be used on the test method level does not make sense.
Примерный перевод: как сделал - так сделал.
Можно вообще не указывать методы, но это снижает удобство навигации:
#[CoversClass('App\Controller\GreetingController')] class HelloTest extends WebTestCase { public function test_hello(): void {...} public function test_bye(): void {...} }
А если методы указывать, получается какая-то гирлянда в Pascal-стиле:
#[CoversMethod('App\Controller\GreetingController', 'hello')] #[CoversMethod('App\Controller\GreetingController', 'bye')] class ByeTest extends WebTestCase { public function test_hello(): void {...} public function test_bye(): void {...} }
Но выход есть! Кинематограф. Парадигма - в одном тестовом классе проверяется один метод.
#[CoversMethod('App\Controller\GreetingController', 'hello')] class HelloTest extends WebTestCase { public function test_hello(): void {...} public function test_hello2(): void {...} }
#[CoversMethod('App\Controller\GreetingController', 'bye')] class ByeTest extends WebTestCase { public function test_bye(): void {...} public function test_bye2(): void {...} }
Мир резко преображается:
Больше тестовых методов: test_hello, test_hello2, ... Почему? Потому что когнитивная нагрузка упала и пришло вдохновение!
Не надо добавлять для каждого из них covers метку.
Желание сохранить соответствие между тестами и кодом подтолкнет к следующему шагу: один контроллер - один эндпоинт. О-о-о, какая легкость, чистота, сцепление!
Если контроллер содержит только один эндпоинт, расположенный в начале класса, то может реально забить на указание метода и использовать CoversClass? К такой мысли подталкивает и Pest (обертка над phpunit) со своей реализацией covers https://pestphp.com/docs/mutation-testing:
covers(HelloController::class); it('starts with specific phrase', function () {...});
Тестовые шаблоны для атрибутов настраиваются аналогично аннотациям:
File:
<?php #parse("PHP File Header.php") #if (${NAMESPACE}) namespace ${NAMESPACE}; #end use App\Tests\Base\WebTestCase; use PHPUnit\Framework\Attributes\CoversMethod; #[CoversMethod('\\${TESTED_NAMESPACE}\\${TESTED_NAME}', '')] class ${NAME} extends WebTestCase { }
Method:
#[CoversMethod('\\${TESTED_NAMESPACE}\\${TESTED_NAME}', '${NAME}')] public function test${CAPITALIZED_NAME}(): void { }
Но раз CoversMethod нельзя располагать над методом, придется после генерации переносить его вверх к классу.
Может быть когда-нибудь в IDE появится поддержка этой особенности?
Может быть даже возле каждого тестового метода появится ссылка, чтобы не скроллить до класса?
Может быть навигация даже будет перебрасывать в базовый класс, если такого метода нет у тестируемого?
Мечты.
Главный изъян covers
Не знаю, удалось ли показать преимущества covers. Особенно в варианте с атрибутами, когда с таким же результатом над классом можно использовать и @see.
Как бы то ни было, время перейти к главному недостатку covers.
Оказывается, он был создан для того, чтобы обрезать тестовое покрытие!
Например, CoversClass ограничивает покрытие рамками класса, CoversMethod - рамками конкретного метода и т.д.
Ригористов, которым это надо, найти непросто. Куда полезней обладать информацией о том, какие участки кода вообще не затронуты тестами (хотя над "объективностью" тестового покрытия не изголялся разве что ленивый, но лучше так, чем никак).
Год от года предпринимаются различные попытки вырубить covers. Вот известные мне:
Переусложненный. Использовать дополнительный скрипт, который перед запуском тестов будет удалять (или переименовывать) cover метки. Пример, https://github.com/t-regx/covers-ignore
Практичный. Пропатчить phpunit/php-code-coverage, и использовать этот патч через https://github.com/cweagans/composer-patches. Делается это одной строкой:
# vendor/phpunit/php-code-coverage/src/CodeCoverage.php private function applyCoversAndUsesFilter(RawCodeCoverageData $rawData, array|false $linesToBeCovered, array $linesToBeUsed, TestSize $size): void { + return;Наивный. Добавить в phpunit возможность из коробки отключать этот режим с помощью флага. Например,
--ignore-covers.
Третий способ сложнее, чем хотелось бы. Модуль для сбора тестового покрытия вынесен в отдельный репозиторий https://github.com/sebastianbergmann/php-code-coverage. Поэтому нужно реализовать поддержку флага там, а также в основном репозитории phpunit, из которого этот модуль вызывается.
Но главная сложность - это упорное нежелание автора библиотеки добавлять этот флаг. Хотя в phpunit уже есть схожие решения (--disable-coverage-ignore, --no-coverage).
Я честно пробовал провернуть этот финт. Вот даже PR-ы в оба репозитория:
К сожалению, спустя какое-то (весьма продолжительное) время все они были закрыты Себастьяном Бергманном с излюбленной формулировкой:
Thank you for your contribution. I appreciate the time you invested in preparing this pull request. However, I have decided not to merge it.
Примерный перевод: Мне настолько плевать на этот PR, что я закрою его даже не объясняя причины.
От жеж ..! Ладно, бывает.
Сам я предпочитаю второй (практичный) вариант с однострочным добавлением return. Его куда проще поддерживать от версии к версии. Подсмотрел его у Gert de Pagter (BackEndTea), который пробовал протолкнуть эту тему еще в 2018 (https://github.com/sebastianbergmann/php-code-coverage/pull/573), за что ему большое спасибо!
Заключение
Использую covers-метки много лет. Эндпоинты из серых (неиспользуемых) превращаются в светящиеся. Тесты заботливо "подвозят" до целевого кода. Коллеги посылают невидимые лучи признательности (но это не точно). ИИ хорошо автоматизирует такой подход при создании новых тестов и добавлении меток к существующим.
Из минусов - немного возни с настройками, небольшой шум при прогоне тестов, когда covers указывает на несуществующие (или находящиеся в базовых классах) методы. Поддержка в IDE могла быть еще лучше (но могла быть и хуже).
Адьёс!
