Є простий спосіб перевірити свій сьют за дві хвилини: відкрийте будь-який клас із бізнес-логікою, замініть > на >= і запустіть тести. Якщо вони зелені, покриття цього рядка нічого не означає. Infection робить те саме автоматично: генерує сотні таких дрібних змін і рахує, скільки з них тести помічають.
Чому 100% покриття нічого не гарантує
Покриття відповідає рівно на одне питання: чи виконувався рядок під час тестів. Чи перевіряв хтось результат, воно не питає.
final readonly class Discount
{
public function forOrder(int $totalCents): int
{
if ($totalCents > 500_000) {
return (int) round($totalCents * 0.1);
}
return 0;
}
}
it('gives ten percent for big orders', function () {
expect((new Discount)->forOrder(1_000_000))->toBe(100_000);
});
it('gives nothing for small orders', function () {
expect((new Discount)->forOrder(100_000))->toBe(0);
});
Обидва рядки методу виконані, звіт покаже 100%. А тепер подивімось на мутації, які Infection згенерує для цих чотирьох рядків:
>→>=(мутаторGreaterThan): межу 500 000 не перевіряє жоден тест, мутант виживає;0.1→1.0? ні,Plus/Minusтут не застосовні, алеDecrementIntegerзробить500_000→499_999, і це теж виживе;return 0→return -1(ReturnValue-профіль) - оцей помре, другий тест його зловить.
Вижив мутант - значить, у коді є зміна поведінки, яку ви можете задеплоїти, а сьют промовчить. Infection міряє саме це, а не кількість пройдених рядків.
Перший запуск Infection на реальному проєкті
composer require --dev infection/infection
Потрібен драйвер покриття. Xdebug працює, але повільний; на pcov прогін відчутно легший, тому для CI беріть його. Доступний драйвер Infection визначає сам, від вас потрібен лише XDEBUG_MODE=coverage або pcov.enabled=1.
Конфіг infection.json5 у корені:
{
"$schema": "vendor/infection/infection/resources/schema.json",
"source": {
"directories": ["src"],
"excludes": ["**/Infrastructure/Migrations"]
},
"logs": {
"text": "build/infection/escaped.log",
"summary": "build/infection/summary.log",
"github": true
},
"mutators": {
"@default": true
},
"tmpDir": "build/infection",
"timeout": 10
}
Ключове рішення тут - source.directories. Не вмикайте одразу весь проєкт. У модульному моноліті є сенс почати з src/*/Domain і src/*/Application: це чистий PHP, тести на нього швидкі, а ціна помилки найвища. Контролери, Livewire-компоненти й Filament-ресурси мутувати рано - у glue-коді набереться купа мутантів, яких ви все одно не збираєтесь вбивати.
Запуск:
XDEBUG_MODE=coverage vendor/bin/infection \
--threads=max \
--only-covered \
--show-mutations
--only-covered каже не генерувати мутантів для непокритого коду. Без цього ви отримаєте гору «Not Covered» і жодної нової інформації: про те, що непокритий код не тестується, вам і так сказав coverage-звіт.
Спершу Infection проганяє сьют повністю, щоб зібрати покриття по рядках і час кожного тесту. Потім для кожного мутанта запускає тільки ті тести, які торкаються зміненого рядка. Тому загальний час приблизно дорівнює одному повному прогону плюс кількість мутантів помножена на час найповільнішого з релевантних тестів, поділене на кількість потоків. З unit-тестами домену це хвилини. Якщо в покриття потраплять feature-тести з RefreshDatabase, час зросте на порядок, а паралельні потоки ще й почнуть битися за одну БД. Другий аргумент на користь того, щоб починати з домену.
Pest підтримується як адаптер: --test-framework=pest (або "testFramework": "pest" у конфігу). Оскільки Pest працює поверх PHPUnit, адаптер phpunit теж зазвичай спрацьовує. Можна обійтись і без Infection: у Pest 3 є вбудоване мутаційне тестування - vendor/bin/pest --mutate --covered-only --min=80 і список класів у Pest.php через ->mutates([...]). Набір мутаторів там менший, зате інтеграція нативна. Далі в тексті я лишаюся на Infection через гнучкіший конфіг.
Як читати вижилих мутантів
У escaped.log кожен вижилий мутант виглядає як діф:
1) src/Hiring/Domain/Salary/Discount.php:12 [M] GreaterThan
--- Original
+++ New
@@ @@
- if ($totalCents > 500_000) {
+ if ($totalCents >= 500_000) {
Далі все зводиться до сортування по трьох купках.
Слабке твердження в тесті. Найчастіший випадок і єдиний, заради якого все затівалось. Мутант на межі > / >= означає, що межового тесту немає. Мутант Plus → Minus у розрахунку, який тести викликають, але результат порівнюють через toBeGreaterThan(0), каже, що твердження надто розмите. Виправляти тут треба тести, а не код.
Еквівалентний мутант. Змінений код поводиться так само, як оригінал, тому вбити його неможливо. Класика - захисне array_unique() над даними, які й без нього унікальні, або < замість <= у циклі, де остання ітерація нічого не змінює. Витрачати на них час безглуздо, такі місця треба заглушити явно:
/** @infection-ignore-all */
public function normalizeTags(array $tags): array
{
return array_values(array_unique($tags));
}
Анотацію можна звузити до конкретного мутатора: @infection-ignore-all GreaterThan. Коли таких місць багато й вони однотипні, дешевше описати правило в конфігу, ніж розсипати анотації по коду:
"mutators": {
"@default": true,
"IncrementInteger": {
"ignoreSourceCodeByRegex": [".*Cache::remember.*"]
},
"MethodCallRemoval": {
"ignore": ["PhpUkraine\\Hiring\\Application\\**\\*::log*"]
}
}
Код, який не має сенсу мутувати. TTL кешу, рівні логування, дефолтні значення в DTO, які ніхто не читає. Мутант виживає чесно, але тест на нього нічого не покращить. Це сигнал звузити source.directories або вимкнути мутатор, а не писати тест.
Окремо про статуси, які не означають «вижив». Errored (мутант зламав код так, що впав fatal error) і Timed Out (мутант зациклив виконання) рахуються як убиті: поведінка змінилась, і сьют це помітив, хай і грубо. А от великий Timed Out при малому timeout у конфігу варто перевірити вручну, бо він може маскувати повільний тест.
Де зупинитись: MSI і час CI
Infection виводить три числа, і плутати їх не варто:
- MSI - частка вбитих мутантів від усіх згенерованих.
- Mutation Code Coverage - частка мутантів, які взагалі потрапили під якийсь тест. Близько до звичайного покриття рядків.
- Covered Code MSI - частка вбитих серед покритих. Оце і є якість тестів; перше число просто змішує її з покриттям.
Ганятися за 100% MSI не треба: після певної позначки залишаються переважно еквівалентні мутанти, і кожен наступний відсоток коштує години. Робочий підхід - храповик. Запустіть Infection один раз, візьміть отриманий Covered Code MSI, округліть униз і зафіксуйте як поріг:
vendor/bin/infection --min-covered-msi=<поточне значення> --threads=max
Поріг ніколи не знижується, а підвищується разом із реальним прогресом. Так метрика не перетворюється на декоративну.
Час у CI вирішується двома прийомами. Перший - не проганяти покриття двічі. Якщо тестова джоба вже його рахує, збережіть XML і JUnit, а Infection запускайте на готовому:
vendor/bin/pest \
--coverage-xml=build/coverage/coverage-xml \
--log-junit=build/coverage/junit.xml
vendor/bin/infection \
--coverage=build/coverage \
--skip-initial-tests \
--threads=max
Другий - на pull request мутувати лише змінені рядки:
vendor/bin/infection \
--git-diff-lines \
--git-diff-base=origin/main \
--logger-github \
--ignore-msi-with-no-mutations \
--threads=max
--logger-github перетворює вижилих мутантів на анотації прямо в дифі PR, тому автор бачить їх там же, де і решту зауважень. --ignore-msi-with-no-mutations рятує від падіння збірки на PR, де змінено лише конфіг і мутантів не згенеровано взагалі. Повний прогін по всьому src лишається на нічний розклад: там час ніхто не рахує, а регресію по MSI ви побачите наступного ранку.
Найчесніший результат перших тижнів з Infection виглядає так: кілька десятків справді слабких тестів, три-чотири місця, де логіка написана «на око» і ніхто цього не перевіряв, і один-два реальні баги на межових значеннях. Далі інструмент переходить у фоновий режим і просто не дає сьюту деградувати.