<? phpukraine СПІВБЕСІДИ
⌕Пошук по платформі
ТЕСТУВАННЯ · MIDDLE

Що показує покриття коду, чого воно не показує і навіщо мутаційне тестування?

Покриття рахує, які рядки виконались під час тестів, але не чи помітив би тест їхню зміну: тест без перевірок дає 100%. Мутаційне тестування вносить у код дрібні зміни (мутанти: `>=` на `>`, `+` на `-`, `true` на `false`) і проганяє тести; мутант, на якому все зелене, вижив і показує неперевірене місце. У PHP це Infection і вбудований у Pest `--mutate`; частку вбитих мутантів показує MSI.

У проєкті 95% покриття, а баги в розрахунках однаково доходять до проду. Як таке можливо?
Яке покриття тестами ви вважаєте достатнім?
Що таке мутант і коли його вважають вбитим?
Чи варто ставити поріг покриття в CI?
покриття коду мутаційне тестування Infection Pest mutate MSI

Покриття коду відповідає на одне питання: які рядки виконались, поки йшли тести. PHPUnit і Pest рахують його через драйвер, Xdebug або PCOV, і показують відсоток та звіт по файлах. Корисне воно передусім як карта: червоні класи з логікою, яких не торкнувся жоден тест, видно одразу. Але з того, що рядок виконався, не випливає, що його перевірили. Тест, який викликає price() і перевіряє лише toBeInt(), дає 100% покриття і не ловить жодної помилки в розрахунку.

До того ж покриття буває різним. Покриття рядків вважає рядок з тернарним оператором чи && повністю покритим, навіть якщо одна з гілок ніколи не виконалась. Покриття гілок і шляхів (у PHPUnit --path-coverage, лише з Xdebug) точніше, але повільніше. І жоден вид не каже, чи стоїть за виконаним рядком змістовна перевірка.

Мутаційне тестування міряє саме це. Інструмент бере мутатор, правило зміни коду, і створює мутанта: копію з однією зміною, як >= на >, + на -, true на false, видалений виклик методу. На кожному мутанті проганяються тести, що покривають змінений рядок. Якщо хоч один упав, мутанта вбито, і це добре. Якщо всі зелені, мутант вижив, тобто цю зміну тести не помічають. Частку вбитих показує MSI (Mutation Score Indicator). В Infection є ще Covered MSI, той самий відсоток лише серед мутантів у покритому коді.

У PHP є два інструменти. Infection працює окремою командою з PHPUnit, Codeception і phpspec, має поріг --min-msi і режим --git-diff-lines для pull request. У Pest 5 мутаційне тестування вбудоване: тест оголошує mutates(Order::class), а vendor/bin/pest --mutate мутує лише вказані класи. Ціна висока: кожен мутант це ще один прогін тестів. Тому мутують домен і розрахунки зі швидкими unit-тестами, на pull request лише змінене, а повний прогін лишають на ніч.

Ні покриття, ні MSI не варто доводити до 100%. Частина мутантів еквівалентна оригіналу, наприклад зміна межі в циклі, з якого все одно виходять раніше, і їх не вбити жодним тестом. Поріг у CI ставлять на рівні вже досягнутого, щоб захиститися від регресу, а мутантів, що вижили, читають по одному: кожен з них або показує пропущену межу, або позначається ігнором як свідомо байдужий.

// src/Shop/Delivery.php
final class Delivery
{
    public function price(int $sum): int
    {
        return $sum >= 2000 ? 0 : 50;   // мутант: >= на >, 2000 на 2001, 0 на 1...
    }
}

// tests/Unit/DeliveryTest.php
mutates(Delivery::class);               // Pest мутує лише цей клас

// Цей тест дає 100% покриття рядків, але межу не перевіряє:
// мутант `$sum > 2000` виживе, бо 5000 більше в обох версіях.
it('is free for big orders', function () {
    expect((new Delivery)->price(5000))->toBe(0);
});

// Ці два вбивають мутанти на межі.
it('is free exactly at the threshold', function () {
    expect((new Delivery)->price(2000))->toBe(0);
});

it('is paid one hryvnia below the threshold', function () {
    expect((new Delivery)->price(1999))->toBe(50);
});

// vendor/bin/pest --mutate --min=90
Що line coverage міряє виконання, а не перевірку, і високий відсоток сумісний з тестами без жодного змістовного асерту.
Механіка мутаційного тестування: мутатор змінює один вираз, тести на цьому мутанті запускаються, killed означає, що хоч один тест упав, escaped (у Pest untested) означає прогалину.
Що таке MSI і чим Covered MSI в Infection відрізняється від нього: другий рахує лише мутантів у покритому коді.
Розуміння ціни: потрібен драйвер покриття (Xdebug або PCOV), кожен мутант означає ще один прогін тестів, тому мутують домен і розрахунки, а на pull request лише змінені рядки.
Що поріг у CI захищає від регресу, але гонитва за 100% (покриття чи MSI) веде до безглуздих тестів, а частина мутантів еквівалентна оригіналу й невбивна.
Казати, що 100% покриття означає відсутність багів або що тести добрі.
Підганяти покриття тестами на геттери, DTO й конфіги, лишаючи без перевірки межі в розрахунках.
Ганяти мутаційне тестування на весь проєкт на кожен коміт: CI іде годинами, і інструмент вимикають.
Вимагати 100% MSI: еквівалентні мутанти (зміна, що не змінює поведінку) не вбиваються жодним тестом, і команда пише дивні тести або здається.
Плутати типи покриття: 100% рядків не означає 100% гілок, рядок `return $a && $b ? x : y;` вважається покритим, навіть якщо одна з гілок жодного разу не виконалась.
ПОРАДА

Покажіть на прикладі: функція доставки з умовою `$sum >= 2000` і тест, що перевіряє лише суму 5000. Покриття 100%, але мутант `>=` на `>` виживає, бо межу 2000 ніхто не перевіряв. Один такий приклад пояснює різницю між покриттям і мутаційним тестуванням краще за визначення.

оновлено 8 жовтня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Покриття міряє виконання: тест без жодного змістовного асерту теж дає високий відсоток. Мутаційне тестування змінює код і дивиться, чи впаде хоч один тест; якщо впав, мутанта вбито, якщо всі зелені, мутант вижив. Інструментам потрібен Xdebug або PCOV, щоб знати, які тести покривають рядок, і кожен мутант коштує прогону тестів, тому повний прогін дорогий.