Мутационное тестирование в PHP: убиваем тесты, чтобы они работали

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

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

Начать курс

Ваши тесты — это иллюзия безопасности?

Представьте: у вас 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 ||

Блоги

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