Розмова про покриття зазвичай починається з кінця: яке число поставити в CI, 70 чи 80. Спершу розберімось, що саме порахував інструмент, бо один і той самий прогін дає три різні відсотки, і відстань між ними більша, ніж між 70 і 80. Приклади нижче на PHP 8.4, PHPUnit 13.3 і Pest 5.1.
Рядки, гілки і шляхи
Типовий метод, який форматує діапазон зарплати:
final readonly class SalaryRange
{
public function label(?int $from, ?int $to): string
{
$parts = [];
if ($from !== null) {
$parts[] = "від {$from}";
}
if ($to !== null) {
$parts[] = "до {$to}";
}
return $parts === [] ? 'не вказано' : implode(' ', $parts);
}
}
Один тест, який передає обидва числа, виконує кожен рядок цього методу. Line coverage: 100%. Це найпоширеніша метрика, саме її показує vendor/bin/pest --coverage, і вона відповідає на питання «чи проходив інтерпретатор через цей рядок».
Гілки вона не бачить. Обидва if спрацювали лише в позитивну сторону, тернарний оператор пішов лише правою віткою, тобто половина рішень у методі не перевірена. Branch coverage рахує кожен вихід з кожного розгалуження окремо:
vendor/bin/phpunit --coverage-text --branch-coverage
Додаємо другий тест з двома null, і гілки теж стають зеленими: обидві умови побували в обох станах, тернарник відпрацював в обидві сторони. А от цікаві випадки досі не перевірені. Комбінацій тут чотири: обидва задані, лише from, лише to, жодного. Два тести закрили дві з них. Саме це рахує path coverage:
vendor/bin/phpunit --coverage-html=build/coverage --path-coverage
Баг у такому методі майже завжди живе в змішаному шляху: зайвий пробіл, коли частина одна, або implode по масиву з одного елемента там, де очікували два. Рядки й гілки про це не скажуть нічого.
Дві практичні деталі. Гілки й шляхи вміє збирати лише Xdebug (XDEBUG_MODE=coverage); з PCOV запит на branch coverage закінчиться BranchCoverageNotSupportedException, бо драйвер віддає тільки рядки. І кількість шляхів у методі росте як добуток розгалужень, тому path coverage має сенс як разовий локальний інструмент на конкретному класі, а не як метрика в CI. Увімкнути обидві гранулярності можна і в phpunit.xml через атрибути branchCoverage та pathCoverage елемента <coverage>.
Що покриття не показує
Виконання не означає перевірку. Feature-тест, який відкриває сторінку вакансії і робить assertStatus(200), проходить через контролер, репозиторій, presenter і шаблон. У звіті це десятки зелених рядків, у сенсі перевірки поведінки там нуль (про те, що в таких тестах перевіряти, є окрема стаття).
Відсоток приховує склад. 85% у проєкті, де непокриті 15% це розрахунок зарплатної вилки й перевірка прав доступу, гірше за 60%, де непокриті лише мапінг DTO і Filament-ресурси. Одне число не розрізняє ці ситуації, а команда дивиться саме на число.
Найнеприємніше: покриття запитує лише про код, який існує. Якщо в label() забули перевірку $to < $from, цей випадок ніде не з'явиться як «не покритий», бо немає рядка, який можна не виконати. Пропущена валідація, забутий else, необроблений null з зовнішнього API: усе це лишає звіт на 100%.
І сам знаменник налаштовується. Покриття рахується по тому, що потрапило в <source><include>: якщо src там немає, домен просто не враховується. includeUncoveredFiles="false" викидає зі звіту файли, які тести жодного разу не завантажили, тобто піднімає відсоток рівно за найгірше протестовані місця. А @codeCoverageIgnore над складним методом працює так само ефективно, як написаний тест, з точки зору звіту. Те, що в PHPUnit є прапорець --disable-coverage-ignore, який ці позначки скасовує, натякає, як часто ними зловживають.
Корисна протиотрута до випадкового покриття - метадані. З requireCoverageMetadata="true" у phpunit.xml кожен тест мусить оголосити, що саме він покриває, і в звіт потрапляє лише заявлений клас, а не все, що встигло виконатись по дорозі. У Pest це covers(SalaryRange::class) на початку файлу або ->covers(...) на окремому тесті. Вмикайте це для tests/Unit; для feature-тестів вимога оголошувати цілі перетворюється на формальність.
Покриття нового коду замість загального
На проєкті з історією загальне покриття - інертне число. Щоб зрушити його на два пункти, потрібні тижні роботи, окремий PR змінює його на 0.3%, і через місяць на цей рядок у звіті вже ніхто не дивиться. Поріг, який неможливо перетнути жодним розумним комітом, дає рівно нуль впливу на код.
Робоча метрика інша: скільки з рядків, доданих або змінених саме в цій гілці, покриті тестами. Це питання має відповідь під час рев'ю, і автор PR може на неї впливати того ж дня.
У Codecov це статус patch поруч зі звичайним project:
coverage:
status:
project:
default:
target: auto
threshold: 0.5%
patch:
default:
target: 80%
target: auto порівнює з базовим комітом, тобто project-статус тут працює як храповик: падати не можна, рости не обов'язково. Жорсткий поріг стоїть лише на дифі.
Без зовнішнього сервісу те саме робить diff-cover по звіту в Cobertura:
vendor/bin/phpunit --coverage-cobertura=build/cobertura.xml
diff-cover build/cobertura.xml --compare-branch=origin/main --fail-under=80
У SonarQube за це відповідає умова Coverage on New Code у quality gate. Інструмент не принципово важливий, важлива зміна правила: легасі лишається непокритим, поки його не чіпають, а щойно хтось редагує файл, зміна приходить із тестом. Цього можна вимагати на рев'ю без суперечок про «історичний борг».
Слабке місце в patch coverage те саме, що і в загальному: його легко задовольнити тестом, який виконує нові рядки й нічого не перевіряє.
Мутаційне тестування як доповнення
Покриття і MSI від Infection міряють різні речі, але не конкурують: покриття - вхідні дані для мутаційного тестування. Мутант у непокритому рядку ніхто не вб'є, тому непокритий код тягне MSI вниз автоматично. Звідси корисна пара метрик: --min-msi уже містить у собі покриття, а --min-covered-msi показує якість тестів окремо від їхньої повноти. Якщо в CI стоять обидві, поріг покриття лишається дешевою ранньою перевіркою, а не головними воротами.
Ціна очевидна: повний мутаційний прогін у рази довший за сьют, тому на PR його ганяють по дифу.
vendor/bin/infection --git-diff-lines --git-diff-base=origin/main --min-covered-msi=80
Як читати вижилих мутантів і з якого MSI починати, розібрано в окремій статті про Infection.
Як ввести поріг у CI
Число береться з поточного стану, не з голови. Запустіть покриття на master, округліть вниз і зафіксуйте цей рівень як підлогу:
vendor/bin/pest --coverage --min=68
Далі підлога рухається тільки вгору і тільки свідомо. Якщо хочеться, щоб CI сам нагадував про підняття, є --exactly=68: збірка падає і коли покриття впало, і коли зросло, тож новий рівень доводиться прописати в конфіг. Прийом дратівливий, але він не дає зібраному запасу тихо розсипатись назад.
Збирайте покриття в окремій джобі, один раз на весь сьют, з PCOV як драйвером:
- uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
coverage: pcov
- run: vendor/bin/pest --coverage --min=68 --coverage-clover=build/clover.xml
Xdebug тут доречний лише тоді, коли вам справді потрібні гілки. Наскільки збір уповільнює саме ваш сьют, міряйте на своєму, а не беріть цифру з чужого блогу; про те, що дає найбільший виграш у часі, є розбір повільного CI.
Чого не робити: порогів на кожен клас окремо. Правило «кожен клас від 90%» безвідмовно генерує тести на геттери, enum-и та DTO, бо це найдешевший спосіб підняти локальний відсоток. Загальна підлога плюс поріг на дифі дають той самий тиск без цієї побічної дії.
Число, яке має сенс, складається з трьох частин: виміряна підлога, яка не падає, поріг на змінених рядках і MSI по дифу. 100% у цьому наборі ціллю не є, бо досягається воно не тестами, а @codeCoverageIgnore над найскладнішими методами і тестами на код, який неможливо зламати.