<? phpukraine СТАТТІ
⌕Пошук по платформі
ТЕСТУВАННЯ 30 вересня 2026 · 7 хв читання

Покриття коду: яке число має сенс і чому 100% не ціль

Один прогін дає три різні відсотки: рядки, гілки, шляхи. Розбираємо, що саме рахує php-code-coverage, чому загальне покриття на проєкті з історією нікого не рятує, і як поставити поріг у CI, щоб він ловив недотестований новий код, а не змушував писати тести на геттери.

РP
Редакція phpukraine
Редакція платформи

Розмова про покриття зазвичай починається з кінця: яке число поставити в 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 над найскладнішими методами і тестами на код, який неможливо зламати.

ПИШЕТЕ ПРО PHP?Опублікуйте розбір або історію з проєкту на платформіРедактор із чеклістом, редактура, авторська сторінка. Републікація з блогу отримує canonical на оригінал. Відкрити редактор →
РP
Редакція phpukraine
Редакція платформи
Матеріали, які готує команда платформи на основі власних даних: каталогу вакансій, зарплатного звіту й банку питань. Кожна цифра в них рахується з бази, а не береться з голови.
оновлено 3 жовтня 2026 · ліцензія CC-BY-SA-4.0
ДАЛІ ПО ТЕМІ
ЧИТАТИ ДАЛІ
← Усі статті