Mutation Testing в PHP: Как писать тесты, которые действительно ловят баги

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение: Почему 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-&                

Блоги

Рекомендаций по книгам