Ваши тесты — это иллюзия безопасности?
Представьте: у вас 95% покрытия кода, все тесты зелёные, CI/CD проходит. Вы уверены, что ваш PHP-код защищён от регрессий. Но так ли это на самом деле?
Я сам долгое время жил в этой иллюзии, пока не столкнулся с ситуацией: тест проходил, хотя я случайно заменил && на || в критическом условии. Тест просто не проверял эту ветку. Покрытие-то было 100%, а логика — нет.
Знакомьтесь: мутационное тестирование (mutation testing) — методика, которая безжалостно «убивает» ваши тесты, чтобы выяснить, насколько они на самом деле надёжны. В этой статье мы разберём, как применить её в PHP, используя мощный инструмент Infection, и почему это меняет правила игры.
Что такое мутационное тестирование и зачем оно нужно?
Мутационное тестирование — это подход, при котором в ваш исходный код вносятся небольшие намеренные изменения (мутации). Например, оператор + меняется на -, true на false, или удаляется целая строка. После каждой мутации запускаются ваши тесты.
Если тест падает — мутация «убита», значит, тест действительно проверяет этот кусок кода. Если тест проходит — мутация «выжила». Это тревожный сигнал: либо тест не проверяет данное условие, либо он написан неправильно.
В отличие от метрики «покрытие кода» (code coverage), которая лишь показывает, какие строки были выполнены, мутационное тестирование проверяет качество проверок. Высокий процент покрытия — не гарантия, а мутационный скор (Mutation Score Indicator, MSI) — реальный показатель надёжности.
Почему это важно для PHP-разработчика?
- Обнаружение «мёртвых» тестов — тех, что всегда зелёные, но ничего не проверяют.
- Выявление пробелов в логике — например, пропущенные граничные условия.
- Повышение уверенности при рефакторинге — вы точно знаете, какие тесты защищают код.
- Экономия времени — вместо ручного аудита тестов вы получаете автоматический отчёт.
Инструменты для мутационного тестирования в PHP
Лидером на PHP-арене является Infection (ранее известный как Humbug). Он легко интегрируется с PHPUnit и Pest, поддерживает множество мутаций и даёт наглядные отчёты.
Другие варианты: Humbug (прародитель, но менее активен) и Mutagen (экспериментальный). Мы сосредоточимся на Infection — это современный стандарт.
Практика: настраиваем Infection в проекте
Давайте установим Infection и запустим на реальном примере. Предположим, у нас есть простой класс для расчёта скидки:
<?php
// src/DiscountCalculator.php
class DiscountCalculator
{
public function calculate(float $price, bool $isPremium): float
{
if ($isPremium) {
return $price * 0.9; // скидка 10%
}
return $price;
}
}
И тест к нему:
<?php
// tests/DiscountCalculatorTest.php
use PHPUnit\Framework\TestCase;
class DiscountCalculatorTest extends TestCase
{
public function testPremiumDiscount(): void
{
$calculator = new DiscountCalculator();
$result = $calculator->calculate(100.0, true);
$this->assertEquals(90.0, $result);
}
public function testRegularPrice(): void
{
$calculator = new DiscountCalculator();
$result = $calculator->calculate(100.0, false);
$this->assertEquals(100.0, $result);
}
}
Покрытие — 100%. Но давайте проверим мутациями.
Установка и запуск Infection
composer require --dev infection/infection
./vendor/bin/infection
После запуска Infection создаст мутации, например:
- Заменит
0.9на0.0(изменит множитель). - Заменит
trueнаfalseв условии. - Удалит весь блок
if.
В консоли вы увидите что-то вроде:
8 mutations were generated:
5 were killed
3 were escaped
0 were errors
0 were timeouts
Mutation Score Indicator (MSI): 62.5%
62.5% — это не очень хорошо. Три мутации выжили. Какие именно? Infection покажет детали:
- Mutation: "ReturnValue" — изменил
return $price * 0.9наreturn $price * 0.0. Тест прошёл? Да, потому что тест проверяет только конкретное значение100.0, а не диапазон. - Mutation: "TrueValue" — заменил
trueнаfalseв условии. Тест упал? Нет, потому чтоtestRegularPriceне проверяет премиум-случай сfalse.
Мы нашли слабые места. Теперь нужно улучшить тесты:
public function testPremiumDiscountEdgeCases(): void
{
$calculator = new DiscountCalculator();
// Проверяем, что скидка применяется именно как умножение на 0.9
$this->assertEquals(90.0, $calculator->calculate(100.0, true));
$this->assertEquals(0.0, $calculator->calculate(0.0, true));
$this->assertEquals(0.9, $calculator->calculate(1.0, true));
}
После добавления таких тестов и повторного запуска MSI должен приблизиться к 100%.
Типы мутаций в Infection
Infection поддерживает десятки мутаторов. Вот самые полезные:
| Мутатор | Что делает | Пример |
|---|---|---|
Plus |
Заменяет + на - |
$a + $b → $a - $b |
Equal |
Заменяет == на != |
$a == $b → $a != $b |
LogicalAnd |
Заменяет && на || |
$a && $b → $a ||
|