<? phpukraine PHP-ПЛАТФОРМА
⌕Пошук по платформі
ТЕСТУВАННЯ · ЯКІСТЬ КОДУ

Мутаційне тестуванняMutation testing

Інструмент навмисно псує ваш код, по одній зміні за раз, і дивиться, чи помітять це тести. Зміна, яку не помітив жоден тест, показує місце, де справжня помилка теж пройшла б.

9 хв читання Middle → Senior
01 · ЩО ЦЕ

Тести для тестів

Покриття каже, які рядки виконались під час тестів, але не каже, чи помітив би тест їхню зміну. Тест, що викликає price() і перевіряє лише toBeInt(), дає 100% покриття й не ловить нічого. Мутаційне тестування перевіряє саме це: вносить у код одну зміну, наприклад >= на >, + на - чи return 0 на return 1, і отримує . На ньому проганяють тести. Якщо хоч один упав, мутанта . Якщо всі зелені, він , і на цьому рядку справжня помилка теж пройшла б непоміченою.

СЛОВНИКМутатор. Правило зміни коду. У Infection це, наприклад, GreaterThanOrEqualTo (>= на >), Plus (+ на -), IncrementInteger (0 на 1). У Pest ті самі ідеї звуться GreaterOrEqualToGreater, PlusToMinus, IncrementInteger.
СЛОВНИКМутант. Копія коду з однією зміною від мутатора. Інструмент створює їх сотні й на кожному проганяє тести, що покривають змінений рядок.
СЛОВНИКВбитий мутант. Хоча б один тест упав на мутанті. Тести помітили зміну, і це добре. Pest рахує таких мутантів як tested.
СЛОВНИКМутант. що вижив

Частку вбитих рахує . У PHP для цього є два інструменти. Infection (infection/infection) працює окремою командою з PHPUnit, phpspec і Codeception, рахує MSI і Covered MSI й валить CI через --min-msi. У Pest 5 плагін pest-plugin-mutate приходить разом із самим Pest: vendor/bin/pest --mutate мутує класи, названі в mutates() або covers() у тестах, а у звіті тих, що вижили, називає untested.

СЛОВНИКMSI. Mutation Score Indicator: відсоток мутантів, які тести вбили. В Infection таймаути й фатальні помилки мутанта теж рахуються вбитими, а Covered MSI рахує той самий відсоток лише серед мутантів у покритому коді.

Ціна висока: кожен мутант означає ще один прогін тестів, які покривають змінений рядок, а щоб знати, які саме, потрібен драйвер покриття, Xdebug або PCOV. Тому мутують домен і розрахунки зі швидкими unit-тестами, а не контролери й конфіг. У PHP Ukraine мутаційне тестування не запускають: pest-plugin-mutate лежить у vendor як залежність Pest 5, але в тестах немає mutates() чи covers(), а deploy/deploy.sh не викликає --mutate.

02 · ВАРІАНТИ

Одна функція, три набори тестів

Shipping::price() робить доставку безкоштовною від 1000 грн, а інакше рахує 60 грн плюс 10 за кілограм. Усі три набори зелені й виконують кожен рядок методу, тобто мають 100% покриття. Перемикайте вкладки й дивіться, скільки з пʼяти мутантів демо нижче кожен з них вбиває.

php ShippingTest.php Без перевірок
1use App\Shipping\Shipping;
2
3mutates(Shipping::class);
4
5it('calculates a price', function () {
6 expect((new Shipping)->price(1500, 2))->toBeInt()
7 ->and((new Shipping)->price(300, 2))->toBeInt();
8});
ЩО ПЕРЕВІРЯЄ
Лише те, що метод повертає int. Обидві гілки виконано, покриття 100%, а вбито нуль мутантів з пʼяти: будь-яка зміна ціни теж дає int.
КОЛИ БРАТИ
Ніколи як єдиний тест. Так виглядають тести, написані заради відсотка покриття, і саме їх мутаційне тестування й викриває.
php ShippingTest.php Два значення
1use App\Shipping\Shipping;
2
3mutates(Shipping::class);
4
5it('ships an expensive cart for free', function () {
6 expect((new Shipping)->price(1500, 2))->toBe(0);
7});
8
9it('charges a cheap cart by weight', function () {
10 expect((new Shipping)->price(300, 2))->toBe(80);
11});
ЩО ПЕРЕВІРЯЄ
Конкретні ціни для дорогого й дешевого кошика. Вбито чотири мутанти з пʼяти. Вижив >= на >: 1500 і 300 далеко від межі, тож різниці між ними для тестів немає.
КОЛИ БРАТИ
Звичайний добрий тест. Мутант, що вижив, показує, якого випадку бракує.
php ShippingTest.php З межею
1use App\Shipping\Shipping;
2
3mutates(Shipping::class);
4
5it('calculates the price', function (int $sum, int $weight, int $price) {
6 expect((new Shipping)->price($sum, $weight))->toBe($price);
7})->with([
8 'expensive cart' => [1500, 2, 0],
9 'exactly 1000 is free' => [1000, 2, 0],
10 'cheap cart' => [300, 2, 80],
11]);
ЩО ПЕРЕВІРЯЄ
Ті самі випадки плюс рівно 1000 грн. На мутанті з > сума 1000 вже не безкоштовна, тест падає, і вбито всі пʼять.
КОЛИ БРАТИ
Для правил з межами: тарифи, знижки, ліміти, дати. Межу варто перевіряти завжди, мутанти лише нагадують про це.
03 · КОД

Запустіть і подивіться

PHP 8.5
1<?php
2
31// Код, який перевіряємо: доставка безкоштовна від 1000 грн.
4function shipping(int $sum, int $weight): int
5{
6 if ($sum >= 1000) {
7 return 0;
8 }
9
10 return 60 + $weight * 10;
11}
12
132// Мутанти: та сама функція з однією зміною. Назви як у мутаторів Infection.
14$mutants = [
15 'GreaterThanOrEqualTo: >= стало >' => fn (int $sum, int $weight): int => $sum > 1000 ? 0 : 60 + $weight * 10,
16 'GreaterThanOrEqualToNegotiation: >= стало <' => fn (int $sum, int $weight): int => $sum < 1000 ? 0 : 60 + $weight * 10,
17 'IncrementInteger: return 0 стало return 1' => fn (int $sum, int $weight): int => $sum >= 1000 ? 1 : 60 + $weight * 10,
18 'DecrementInteger: 60 стало 59' => fn (int $sum, int $weight): int => $sum >= 1000 ? 0 : 59 + $weight * 10,
19 'Plus: + стало -' => fn (int $sum, int $weight): int => $sum >= 1000 ? 0 : 60 - $weight * 10,
20];
21
223// Тести: назва, аргументи, очікувана ціна. Разом вони виконують кожен рядок shipping().
23$tests = [
24 ['дорогий кошик їде безкоштовно', [1500, 2], 0],
25 ['дешевий кошик платить за вагу', [300, 2], 80],
26];
27
284/**
29 * Проганяє тести на функції й повертає назву першого, що впав, або null.
30 *
31 * @param list<array{string, array{int, int}, int}> $tests
32 */
33function firstFailure(callable $fn, array $tests): ?string
34{
35 foreach ($tests as [$name, $args, $expected]) {
36 if ($fn(...$args) !== $expected) {
37 return $name;
38 }
39 }
40
41 return null;
42}
43
44/** @param list<array{string, array{int, int}, int}> $tests */
45function mutationRun(array $mutants, array $tests): void
46{
47 $killed = 0;
48
49 foreach ($mutants as $label => $mutant) {
50 $failed = firstFailure($mutant, $tests);
51 $killed += $failed === null ? 0 : 1;
52 echo $failed === null ? ' ✗ ВИЖИВ ' : ' ✓ вбито ', $label, $failed === null ? '' : " (впав тест «{$failed}»)", "\n";
53 }
54
55 printf(" MSI: %d з %d, %d%%\n\n", $killed, count($mutants), intdiv(100 * $killed, count($mutants)));
56}
57
585echo 'Тести на справжньому коді: ', firstFailure('shipping', $tests) === null ? 'зелені' : 'червоні', "\n\n";
59
60echo "Прогін 1: два тести\n";
61mutationRun($mutants, $tests);
62
636// Мутант, що вижив, підказує, якого тесту бракує: рівно 1000 грн.
64$tests[] = ['межа: рівно 1000 грн уже безкоштовно', [1000, 2], 0];
65
66echo "Прогін 2: додали тест на межу\n";
67mutationRun($mutants, $tests);
Перший запуск завантажить рушій PHP 8.5, приблизно 3,8 МБ Відкрити в пісочниці ↗
ВИВІД
Завантажуємо рушій PHP 8.5
Лише перший раз. Далі код запускається миттєво, рушій лишається в кеші браузера.
$ php main.php ▍
  1. 1 рядки 3–11

    Код, який перевіряємо. Межа 1000 грн тут найлегше місце для помилки на одиницю.

  2. 2 рядки 13–20

    Пʼять мутантів, кожен з однією зміною. Infection і Pest створюють їх самі, проходячи по синтаксичному дереву коду.

  3. 3 рядки 22–26

    Два тести. Обидві гілки shipping() виконуються, тобто покриття рядків 100%.

  4. 4 рядки 28–56

    Крихітний раннер: для кожного мутанта шукає перший тест, що впав. Немає такого тесту, і мутант вижив.

  5. 5 рядки 58–61

    Спершу тести мають бути зелені на справжньому коді, інакше мутації нічого не означають. Потім прогін: 4 з 5, MSI 80%.

  6. 6 рядки 63–67

    Мутант, що вижив, підказав тест на межу. Після нього вбито всіх.

  7. Наведіть на пояснення, щоб підсвітити рядки.Торкніться пояснення, щоб підсвітити рядки.
04 · ТИПОВІ ПОМИЛКИ

Як мутаційне тестування стає тягарем

✕ 1

Гонитва за 100% MSI

Частина мутантів поводиться так само, як оригінал: наприклад, зміна межі в циклі, який усе одно виходить раніше. Їх не вбити жодним тестом, і команда пише дивні тести або здається.

Натомість: ставте поріг, якого вже досягли, і піднімайте його: --min-msi в Infection, --min у Pest. Мутантів, що вижили, читайте по одному, а свідомо байдужий рядок позначайте коментарем // @pest-mutate-ignore у Pest або ignore для мутатора в конфігу Infection.

✕ 2

Увесь проєкт на кожен коміт

Тисяча класів, по десятку мутантів на клас, і на кожен прогін тестів. CI іде годинами, і мутаційне тестування вимикають.

Натомість: у Pest мутуйте лише класи з mutates() у тестах, які ви змінюєте. В Infection на pull request є --git-diff-lines: мутуються лише додані й змінені рядки. Повний прогін залиште на ніч.

✕ 3

Мутації коду без логіки

Мутанти в DTO, конфігах, міграціях і контролерах, що лише передають запит далі. Вбити їх можна тільки повільними feature-тестами, а знайдена «прогалина» нічого не важить.

Натомість: мутуйте домен і розрахунки: тарифи, знижки, права, парсинг. Там мутант, що вижив, справді означає непомічену помилку.

05 · ДЕРЕВО РІШЕНЬ

Чи варто запускати мутаційне тестування тут

1
У цьому коді є умови й обчислення, які можна зламати на одиницю?
Тарифи, знижки, ліміти, права доступу, розбір дат. Не DTO й не контролер, що лише передає запит.
НІ →Не варто. Тут достатньо feature-тесту й PHPStan, мутанти лише додадуть шуму.
2
Тести цього коду швидкі, без бази й HTTP?
Кожен мутант означає ще один прогін усіх тестів, що покривають змінений рядок.
НІ →Спершу винесіть логіку в домен з unit-тестами. Feature-тести, помножені на сотні мутантів, тягнуться годинами.
3
Ви готові розбирати кожного мутанта, що вижив, і вирішувати, що з ним робити?
Кожен такий мутант означає або тест, якого бракує, або рядок, свідомо позначений як байдужий.
НІ →Почніть з одного класу: mutates(Shipping::class) у його тесті й vendor/bin/pest --mutate локально.
ТАК НА ВСІ →Pest --mutate з mutates() у тестах цього модуля або Infection з --git-diff-lines на кожен pull request і порогом --min-msi.
06 · СЛОВНИК
Мутатор
Правило зміни коду. У Infection це, наприклад, GreaterThanOrEqualTo (>= на >), Plus (+ на -), IncrementInteger (0 на 1). У Pest ті самі ідеї звуться GreaterOrEqualToGreater, PlusToMinus, IncrementInteger.
Мутант
Копія коду з однією зміною від мутатора. Інструмент створює їх сотні й на кожному проганяє тести, що покривають змінений рядок.
Вбитий мутант
Хоча б один тест упав на мутанті. Тести помітили зміну, і це добре. Pest рахує таких мутантів як tested.
Мутант
що вижив
MSI
Mutation Score Indicator: відсоток мутантів, які тести вбили. В Infection таймаути й фатальні помилки мутанта теж рахуються вбитими, а Covered MSI рахує той самий відсоток лише серед мутантів у покритому коді.
07 · ПЕРЕВІР СЕБЕ

Питання зі співбесід

Усі питання з тестування →

Мутант це копія коду з однією навмисною зміною, наприклад >= замінено на >. На ньому запускають тести. Якщо хоч один упав, мутанта вбито: тести помітили зміну. Якщо всі зелені, мутант вижив, і цей рядок насправді не перевірено.

Покриття рахує виконані рядки, а не перевірені. Тест, що викликає метод і перевіряє лише тип результату, покриває все й не ловить жодної помилки в розрахунку. Мутаційне тестування міряє інше: чи впаде хоч один тест, якщо рядок змінити.

MSI рахує вбитих серед усіх мутантів, разом з тими, що в рядках, яких не виконує жоден тест. Covered MSI рахує лише серед мутантів у покритому коді, тобто показує якість наявних тестів без поправки на непокриті місця. Високий Covered MSI при низькому MSI означає: тести добрі, але їх замало.

Не на весь код одразу. Беруть модулі з логікою й швидкими unit-тестами, на pull request мутують лише змінені рядки (--git-diff-lines в Infection) або класи з mutates() у Pest, паралелять (--threads у Infection, --parallel у Pest), а повний прогін ставлять на ніч. Поріг --min-msi фіксують на досягнутому рівні й піднімають поступово, так само як рівень PHPStan.