Skip to content

Commit c564714

Browse files
committed
Add self-mutation article
1 parent da6fb7c commit c564714

4 files changed

Lines changed: 257 additions & 0 deletions

File tree

blog/self-mutation.md

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
---
2+
title: "Who Mutates the Mutator"
3+
date: 2026-07-01
4+
description: "What happens when mutation testing tries to mutate the very tool it tests with."
5+
author: Aleksei Gagarin
6+
image: /blog/self-mutation/preview.png
7+
---
8+
9+
# Who Mutates the Mutator?
10+
11+
Mutation testing works on a simple idea: the tool makes a small change to your code — a mutant — and checks whether your tests catch it. If a test fails, the mutant is "killed" and your checks are doing their job. If nothing fails, the mutant "survives" — and that means there's a hole somewhere.
12+
13+
All of this rests on one silent assumption: **the observer never changes — only the object does.** The framework stays the same; only the code beneath it breaks — and the difference is plain to see. But what if the framework tests itself? Then the observer becomes part of the observed system, and the assumption falls apart.
14+
15+
## How it breaks in Testo
16+
17+
Run Infection over the <plugin>Filter</plugin> plugin — the one that selects tests by `--path`, `--group`, `--filter`, and so on — and at least one mutant will stubbornly "survive", even though it genuinely breaks the code.
18+
19+
The explanation turned out to be an elegant one. To check a mutant, Infection runs the tests that cover it — and it picks which ones through `--filter`, that is, through the very code that's mutated right now. From there the chain writes itself:
20+
21+
> Mutate the filter → the filter breaks → Testo can't tell which test to run → no test runs at all → nothing to fail → Infection sees "success" → the mutant is marked as survived.
22+
23+
Note: this is **not an equivalent mutant**. An equivalent mutant doesn't change behavior for any input.
24+
25+
In a situation like this, you can't even be sure the killed mutants are being killed for the right reason.
26+
27+
As a fix, I could exclude <plugin>Filter</plugin> from mutation testing — and then every other plugin too, since they all share the problem. No, that's not a fix.
28+
29+
What if I ran the tests on a different framework? I'd still want to keep writing them in Testo, though.
30+
31+
Writing a shim to run Testo tests on PHPUnit is a dead end: PHPUnit isn't up to it. But what if I rewrote the tests from Testo to PHPUnit where possible, and ran Infection + PHPUnit on those?
32+
33+
That leaves "just" one thing: learning to rewrite tests from one framework to another without losing their meaning. For that there's [Rector](https://github.com/rectorphp/rector), which walks the AST and rewrites code according to rules.
34+
35+
::: info
36+
Actually, the whole idea came from [@samdark](https://github.com/samdark): I showed him the [PEST-to-PHPUnit](https://github.com/HelgeSverre/pest-to-phpunit) rule set, he suggested doing the same for Testo → PHPUnit, and I loved it.
37+
38+
You might think I'm playing for the wrong team, but let me remind you: Testo is on the developer's side. I often see people regret picking PEST and wishing they could go back to PHPUnit. The same could happen with Testo — so why not help them do exactly that?
39+
:::
40+
41+
That's how the `testo/bridge-rector` package was born: a set of conversion rules plus the harness to test them.
42+
43+
## The Rector harness
44+
45+
Rector transforms code through rules. But every rule needs to be tested somehow. Rector has its own fixture format for this: `*.php.inc` files holding the "input" and the "expected output", plus <class>\Testo\Bridge\Rector\Testing\Internal\RectorRunner</class> to run them. Handy: one fixture — one self-contained "before → after" case.
46+
47+
Rector's native harness runs on PHPUnit through some scaffolding: for each rule you create a separate test class extending <class>\Rector\Testing\PHPUnit\AbstractRectorTestCase</class>, and inside it always the same thing — a data provider of fixture paths and a cookie-cutter test method.
48+
49+
```php
50+
final class MarkTestIncompleteRectorTest extends AbstractRectorTestCase
51+
{
52+
#[DataProvider('provideData')]
53+
public function test(string $filePath): void
54+
{
55+
$this->doTestFile($filePath);
56+
}
57+
58+
public static function provideData(): Iterator
59+
{
60+
return self::yieldFilesFromDirectory(__DIR__ . '/Fixture');
61+
}
62+
}
63+
```
64+
65+
That's a lot of boilerplate, isn't it? Testo isn't that clunky, so I built myself a nicer harness: a single attribute on the rule itself, pointing at the fixtures folder.
66+
67+
```php
68+
#[TestRectorFixtures('MarkTestIncompleteRector')]
69+
final class MarkTestIncompleteRector extends AbstractRector { /* … */ }
70+
```
71+
72+
It works on the same principle as the <plugin>Inline</plugin> plugin: each <attr>\Testo\Bridge\Rector\Testing\TestRectorFixtures</attr> turns into a Data Provider, and each `*.php.inc` into a Data Set. The input and the expected output are piped into channels. No boilerplate classes, and all the detail right there in front of you.
73+
74+
![Fixture in channels](/blog/self-mutation/img-01.png)
75+
76+
If you write Rector rules and use Testo, this harness is available to you too — just add <class>\Testo\Bridge\Rector\Testing\RectorTestingPlugin</class> to `testo.php`.
77+
78+
## The Rector rules
79+
80+
As long as conversion was "assert here, assert there", everything looked smooth. The real work started where the two frameworks diverge in semantics — and they diverge more often than you'd think.
81+
82+
For example:
83+
- **`$this` in PHPUnit needs a class context.** Static test methods have to be turned into regular ones.
84+
- **You can't use a constructor or destructor in PHPUnit.**
85+
Fine, they get converted into a `#[Before]`/`#[After]` hook. Not the same thing, but better than nothing.
86+
- **Unfold the chain without shooting yourself in the foot.** Assertions like `Assert::array($log->all())->isList()` expand into several lines in PHPUnit: `assertIsArray($x)` + `assertIsList($x)`. But `$log->all()` mustn't be evaluated twice (what if there's a side effect?), so the subject is hoisted into a local variable. And its name has to be chosen so it doesn't clobber one already in the method — hence the `$value`, `$value2`, `$value3`… generator.
87+
- **In Pest, a test is a call that takes a closure, not an explicit declaration** — and full of magic and `$this` on top of that. You either turn them into functions or write yet another plugin; for now I've settled on functions.
88+
89+
Pest? Well, I just figured… since I was writing Testo → PHPUnit conversion rules anyway, why not make them bidirectional and toss Pest into the mix too? That's how three rule sets were born:
90+
- **testo-to-phpunit**
91+
- **phpunit-to-testo**
92+
- **pest-to-testo**
93+
94+
## Feature parity
95+
96+
Not every feature is translatable — and that's fine:
97+
98+
- **Mocks** (`createMock`, `prophesize`) → Testo has no built-in mocking. The [Mockery-integration issue](https://github.com/php-testo/testo/issues/41) has been open for a while. Any takers for a bridge?
99+
- **Retry** and **Repeat** are planned for PHPUnit 13.3, but aren't there yet.
100+
- **memory-leak** checks exist only in Testo.
101+
- A DataProvider in Testo can be any `callable` — even a non-static method, or a closure right in the attribute. Plus strategies like <attr>\Testo\Data\DataCross</attr> and <attr>\Testo\Data\DataZip</attr>. Converting it is doable, but no longer trivial.
102+
- Inline tests and benchmarks are easier to just bury than to convert.
103+
- Statuses like `Cancelled` and `Aborted` simply don't exist in PHPUnit — you have to reach for the nearest stand-in.
104+
- Running in a separate process isn't supported in Testo.
105+
106+
Such cases aren't dropped silently — they get documented stub rules and an entry in `TODO.md`. In user tests, whatever can't be converted is marked `skipped` with a reason.
107+
108+
## The mirror
109+
110+
Let's come back to the original problem — mutation testing by an outside observer.
111+
112+
Now a single command copies every test (except `Self`/`Inline`/`Bench`) into a temp folder and converts it to PHPUnit. The result is a set of mirror tests that then run against the original code — but this time under Infection + PHPUnit.
113+
114+
> Like nesting dolls: Testo tests the Rector rules that rewrite Testo tests into PHPUnit, so that PHPUnit can test — and Infection can mutate — Testo's own code.
115+
116+
## What came of it
117+
118+
The observer is finally on the outside.
119+
120+
- The mirror builds and stays green: **864 tests, 0 errors or failures**, ~37 skips (couldn't be converted), a single benign-risky.
121+
- The agent skill for PHPUnit → Testo conversion was extended with the Rector scripts.
122+
- Infection now has **two fronts**: Testo and PHPUnit.
123+
- Mutants die reliably.
124+
125+
It's too early to celebrate, though. The `Self` tests don't make it into the mirror, and they cover an order of magnitude more code than ordinary unit tests — so Infection still sees only a small fraction of the mutants. But at least it's something to work with now.
177 KB
Loading
910 KB
Loading

0 commit comments

Comments
 (0)