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