Skip to content
...
Блог
Кто мутирует мутатора

Кто мутирует мутатора?

Мутационное тестирование устроено просто: инструмент вносит в код мелкую поломку — мутанта — и смотрит, заметят ли её тесты. Заметили, тест упал — мутант «убит», проверки работают. Не заметили — мутант «выжил», значит где-то дыра.

Всё это держится на одном молчаливом допущении: наблюдатель неизменен, меняется только объект. Фреймворк остаётся собой, ломается лишь код под ним — и разница видна. Но что, если фреймворк тестирует сам себя? Тогда наблюдатель становится частью наблюдаемой системы, и допущение рушится.

Как это ломается в Testo

Если прогнать Infection по плагину Filter — тому, что отбирает тесты по --path, --group, --filter и т.д., то как минимум один мутант будет упорно «выживать», хотя код он ломает по-настоящему.

Разгадка оказалась красивой. Чтобы проверить мутанта, Infection запускает покрывающие его тесты — а какие именно, выбирает через --filter, то есть через тот самый код, который сейчас мутирован. Дальше цепочка складывается сама:

Мутируешь фильтр → фильтр ломается → Testo не понимает, какой тест запускать → тест не запускается вовсе → падать нечему → Infection видит «успех» → мутант помечен выжившим.

Важно: это не эквивалентный мутант. Эквивалентный не меняет поведения ни при каком входе.

В такой ситуации нельзя быть уверенным даже в том, что убитые мутанты убиваются правильно.

В качестве решения можно было бы исключить Filter из мутационного тестирования, а потом и все остальные плагины, ведь проблема для них та же. Нет, это не решение.

А что если запускать тесты на другом фреймворке? При этом тесты хочется всё так же писать на Testo.

Писать шим для запуска тестов Testo на PHPUnit — не вариант: PHPUnit не вывезет. Но что, если попробовать переписать тесты с Testo на PHPUnit там, где это возможно, и запускать уже Infection + PHPUnit?

Осталось «всего лишь» научиться переписывать тесты с одного фреймворка на другой без потери смысла. Для этого есть Rector, который умеет ходить по AST и переписывать код по правилам.

Вообще, идею сделать правила конвертирования Testo → PHPUnit мне предложил @samdark, когда я показал набор правил PEST-to-PHPUnit, и мне она очень понравилась.

Можно подумать, что я играю не в те ворота, но напомню, что Testo на стороне разработчика. Я часто вижу, что люди жалеют о выборе PEST и хотели бы вернуться на PHPUnit. То же самое может произойти и с Testo, так почему бы не помочь им в этом?

Так появился пакет testo/bridge-rector с набором правил конвертации + обвязка, чтобы эти правила тестировать.

Обвязка Rector

Rector изменяет код через правила. Но каждое правило нужно как-то тестировать. У Rector для этого есть свой формат фикстур: файлы *.php.inc, в которых «вход» и «ожидаемый выход», и RectorRunner\Testo\Bridge\Rector\Testing\Internal\RectorRunner для запуска. Удобно: одна фикстура — один самодостаточный кейс «было → стало».

Нативная обвязка Rector работает на PHPUnit через костыли: на каждое правило создаётся отдельный тестовый класс-наследник AbstractRectorTestCase\Rector\Testing\PHPUnit\AbstractRectorTestCase, в котором одно и то же: Data Provider путей к фикстурам и однотипный метод теста.

php
final class MarkTestIncompleteRectorTest extends AbstractRectorTestCase
{
    #[DataProvider('provideData')]
    public function test(string $filePath): void
    {
        $this->doTestFile($filePath);
    }

    public static function provideData(): Iterator
    {
        return self::yieldFilesFromDirectory(__DIR__ . '/Fixture');
    }
}

Многовато боилерплейта, не так ли? Testo не такой топорный, поэтому я сделал для себя обвязку удобнее: через один атрибут на самом правиле с указанием папки с фикстурами:

php
#[TestRectorFixtures('MarkTestIncompleteRector')]
final class MarkTestIncompleteRector extends AbstractRector { /* … */ }

Работает по тому же принципу, что плагин Inline: каждый #[TestRectorFixtures]#[TestRectorFixtures(string ...$paths)]Объявляет фикстуры, которые проверяют правило Rector. превращается в Data-Provider, а *.php.inc в Data-Set. При этом входные данные и ожидаемый выхлоп транслируются в каналы. Никаких лишних классов и максимум информации.

Fixture in channels

Если вы создаёте правила Rector и используете Testo, то эта обвязка доступна и вам — просто добавьте RectorTestingPlugin\Testo\Bridge\Rector\Testing\RectorTestingPlugin в testo.php.

Правила Rector

Пока конвертация была «assert туда, assert сюда», всё выглядело гладко. Настоящая работа началась там, где два фреймворка расходятся в семантике — а расходятся они чаще, чем кажется.

Например:

  • $this в PHPUnit требует контекст класса. Приходится конвертировать статические методы тестов в обычные.
  • Конструктор и деструктор в PHPUnit использовать нельзя. Окей, конвертируем в хук с #[Before]/#[After]. Это не то же самое, но лучше, чем ничего.
  • Разложить цепочку и не отстрелить ногу. Утверждения вида Assert::array($log->all())->isList() в PHPUnit разворачиваются в несколько строк: assertIsArray($x) + assertIsList($x). Но $log->all() нельзя вычислять дважды (а вдруг там побочный эффект?), поэтому subject выносится в локальную переменную. И имя ей надо подобрать так, чтобы не затоптать уже существующую в методе, — отсюда генератор $value, $value2, $value3
  • У Pest тесты — это вызовы с передачей кложуры, а не чёткие декларации. При этом куча магии и $this внутри. Нужно превращать в функции или писать ещё один плагин. Пока остановился на функциях.

Pest? Да я просто подумал... Раз дело дошло до правил конвертации Testo → PHPUnit, то почему бы не сделать их двунаправленными и не добавить сюда ещё и Pest? Так родились три набора правил:

  • testo-to-phpunit
  • phpunit-to-testo
  • pest-to-testo

Фича-парити

Не все фичи переводимы — и это нормально:

Такие случаи не выкидываются молча — на них стоят задокументированные stub-правила и запись в TODO.md. В пользовательских тестах то, что не конвертируется, помечается skipped с причиной.

Зеркало

Вернёмся к исходной проблеме — мутационному тестированию сторонним наблюдателем.

Теперь все тесты (кроме Self/Inline/Bench) запуском одной команды копируются во временную папку и конвертируются в PHPUnit. Получаются зеркальные тесты, которые потом запускаются на оригинальном коде, но уже в связке Infection + PHPUnit.

Матрёшка тестирования: Testo тестирует Rector-правила, которые переписывают тесты Testo в PHPUnit, чтобы PHPUnit смог протестировать, а Infection отмутировать код Testo.

Что получилось

Наблюдатель наконец снаружи.

  • Зеркало собирается и зеленеет: 864 теста, 0 ошибок и падений, ~37 скипов (не удалось сконвертировать), единичный benign-risky.
  • Расширен агентский скилл по конвертации PHPUnit → Testo за счёт скриптов Rector.
  • У Infection теперь два фронта с Testo и PHPUnit.
  • Мутанты мрут надёжно.

Праздновать рано. В зеркало не попадают Self-тесты, покрывающие на порядок больше кода, чем обычные Unit-тесты — Infection видит лишь малую долю мутантов. Но хотя бы с этим уже можно работать.