Кто мутирует мутатора?
Мутационное тестирование устроено просто: инструмент вносит в код мелкую поломку — мутанта — и смотрит, заметят ли её тесты. Заметили, тест упал — мутант «убит», проверки работают. Не заметили — мутант «выжил», значит где-то дыра.
Всё это держится на одном молчаливом допущении: наблюдатель неизменен, меняется только объект. Фреймворк остаётся собой, ломается лишь код под ним — и разница видна. Но что, если фреймворк тестирует сам себя? Тогда наблюдатель становится частью наблюдаемой системы, и допущение рушится.
Как это ломается в 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 путей к фикстурам и однотипный метод теста.
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 не такой топорный, поэтому я сделал для себя обвязку удобнее: через один атрибут на самом правиле с указанием папки с фикстурами:
#[TestRectorFixtures('MarkTestIncompleteRector')]
final class MarkTestIncompleteRector extends AbstractRector { /* … */ }Работает по тому же принципу, что плагин Inline: каждый #[TestRectorFixtures]#[TestRectorFixtures(string ...$paths)]Объявляет фикстуры, которые проверяют правило Rector. превращается в Data-Provider, а *.php.inc в Data-Set. При этом входные данные и ожидаемый выхлоп транслируются в каналы. Никаких лишних классов и максимум информации.

Если вы создаёте правила 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
Фича-парити
Не все фичи переводимы — и это нормально:
- Моки (
createMock,prophesize) → у Testo нет встроенного мокинга. Задача на интеграцию Mockery висит давно. Есть желающие сделать бридж? - Retry, Repeat в PHPUnit планируется с версии 13.3, но сейчас нет.
- Проверки memory-leak есть только в Testo.
- DataProvider в Testo может быть любой
callable, даже нестатический метод или кложура прямо в атрибуте. Плюс разные стратегии типа #[DataCross]#[DataCross(DataProviderAttribute ...$providers)]Создаёт все возможные комбинации из провайдеров (декартово произведение). и #[DataZip]#[DataZip(DataProviderAttribute ...$providers)]Объединяет провайдеры попарно по индексу.. Вроде конвертнуть и можно, но уже не так просто. - Inline-тесты и бенчи проще сразу похоронить.
- Разных статусов типа
CancelledиAbortedв PHPUnit просто нет, приходится подбирать аналоги. - Запуск в отдельном процессе в 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 видит лишь малую долю мутантов. Но хотя бы с этим уже можно работать.
