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

Мутаційне тестування з Infection: що воно знаходить у зелених тестах

Зелений сьют і 100% покриття кажуть лише те, що рядки виконувались. Infection ламає код навмисно й показує, які зміни ваші тести пропускають. Розбираємо перший запуск, читання вижилих мутантів і те, як не втопити CI.

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

Є простий спосіб перевірити свій сьют за дві хвилини: відкрийте будь-який клас із бізнес-логікою, замініть > на >= і запустіть тести. Якщо вони зелені, покриття цього рядка нічого не означає. 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.11.0? ні, Plus/Minus тут не застосовні, але DecrementInteger зробить 500_000499_999, і це теж виживе;
  • return 0return -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) {

Далі все зводиться до сортування по трьох купках.

Слабке твердження в тесті. Найчастіший випадок і єдиний, заради якого все затівалось. Мутант на межі > / >= означає, що межового тесту немає. Мутант PlusMinus у розрахунку, який тести викликають, але результат порівнюють через 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 виглядає так: кілька десятків справді слабких тестів, три-чотири місця, де логіка написана «на око» і ніхто цього не перевіряв, і один-два реальні баги на межових значеннях. Далі інструмент переходить у фоновий режим і просто не дає сьюту деградувати.

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