Введение: Почему 100% покрытие кода — это иллюзия?
Вы когда-нибудь гордились отчётом PHPUnit, где написано «Code Coverage: 98%»? А потом выкатывали релиз, и на проде падал критический баг. Знакомо? Проблема в том, что классическое покрытие (line/statement coverage) измеряет только то, какие строки кода были выполнены. Оно не проверяет, проверяют ли ваши тесты логику.
Представьте, что вы написали тест, который проверяет, что функция возвращает true. А сама функция всегда возвращает true, даже если её сломать. Тест пройдёт, покрытие будет 100%, но баг вы не поймаете. Именно здесь на помощь приходит mutation testing в PHP — техника, которая переворачивает привычное представление о тестировании.
В этой статье мы разберём, что такое мутационное тестирование, как оно работает на примере реального PHP-кода, и как внедрить его в ваш проект с помощью библиотеки Infection.
Что такое Mutation Testing? (И почему это не магия)
Mutation testing (или мутационное тестирование) — это метод оценки качества тестов, при котором в исходный код вносятся небольшие намеренные изменения (мутации). Если после изменения тест падает — мутант убит, тест работает. Если тест остаётся зелёным — мутант выжил, а значит, ваш тест ничего не проверяет.
Простая аналогия: вы — охранник, а ваши тесты — камеры. Мутационное тестирование запускает в здание «злоумышленников» (мутантов). Если камера (тест) замечает злоумышленника — она работает. Если злоумышленник спокойно гуляет — камера бесполезна.
В PHP основным инструментом для этого является Infection — библиотека, которая запускает PHPUnit на каждом мутанте и анализирует результаты.
Как это выглядит на практике?
Допустим, у вас есть простой класс калькулятора:
<?php
class Calculator
{
public function add(int $a, int $b): int
{
return $a + $b;
}
}
И тест к нему:
<?php
use PHPUnit\Framework\TestCase;
class CalculatorTest extends TestCase
{
public function testAdd()
{
$calc = new Calculator();
$result = $calc->add(2, 3);
$this->assertEquals(5, $result);
}
}
Инструмент Infection создаст мутанта, заменив + на -:
return $a - $b;
Затем запустит тест. Если тест упадёт — мутант убит. Если тест пройдёт (а он пройдёт, потому что вы проверяете только 2+3, а не 2-3) — мутант выжил. Это сигнал: ваш тест недостаточно хорош.
Установка Infection и первый запуск
Для начала установим Infection через Composer:
composer require --dev infection/infection
Затем создадим файл конфигурации infection.json (или запустите vendor/bin/infection для интерактивной настройки).
Пример минимальной конфигурации:
{
"source": {
"directories": [
"src"
]
},
"phpUnit": {
"configDir": "."
},
"mutators": {
"@default": true
}
}
Запускаем мутационное тестирование:
vendor/bin/infection --min-msi=100
Где --min-msi=100 означает, что мы хотим, чтобы 100% мутантов были убиты (идеальный, но редко достижимый показатель). Infection создаст отчёт в формате HTML (папка infection.html), где красным будут отмечены выжившие мутанты.
Типы мутаций в PHP (на примерах)
Infection поддерживает десятки мутаторов. Вот самые распространённые, с которыми вы столкнётесь:
1. Математические операторы
Замена + на -, * на /, % на * и т.д.
// Исходный код
return $price * $quantity;
// Мутант
return $price / $quantity;
Как защититься: тестировать не только «стандартный» сценарий, но и граничные значения, а также проверять, что умножение не превращается в деление.
2. Логические операторы
Замена && на ||, === на !== и наоборот.
// Исходный код
if ($user->isActive() && $user->hasRole('admin')) { ... }
// Мутант
if ($user->isActive() || $user->hasRole('admin')) { ... }
Как защититься: тестировать комбинации условий: когда оба true, когда одно false, когда оба false.
3. Удаление условий (if-return)
Infection может удалить целый блок if или заменить его на true/false.
// Исходный код
if ($value < 0) {
throw new InvalidArgumentException();
}
return $value * 2;
// Мутант (удалён if)
return $value * 2;
Как защититься: всегда писать тест, который проверяет, что исключение выбрасывается при невалидных данных.
4. Инкремент/декремент
Замена $i++ на $i-- или наоборот.
// Исходный код
for ($i = 0; $i < 10; $i++) { ... }
// Мутант
for ($i = 0; $i < 10; $i--) { ... } // Бесконечный цикл!
Как защититься: тестировать граничные значения цикла (0, 1, 10, 11) и убедиться, что цикл не становится бесконечным.
Практический пример: убиваем всех мутантов
Давайте возьмём более сложный пример — функцию проверки email:
<?php
class Validator
{
public function isValidEmail(string $email): bool
{
if (empty($email)) {
return false;
}
if (strpos($email, '@') === false) {
return false;
}
$parts = explode('@', $email);
if (count($parts) !== 2) {
return false;
}
if (strlen($parts[1]) < 3) {
return false;
}
return true;
}
}
Напишем «ленивый» тест, который даст 100% покрытие строк, но пропустит мутантов:
public function testValidEmail()
{
$v = new Validator();
$this->assertTrue($v-&