Code, dessen Ergebnis niemand liest, wird trotzdem gepflegt. In PHPUnit waren das ein paar Zeilen in TestRunner::run(), die seit April 2023 keine Wirkung mehr hatten. Sie steckten in mehr als 300 Releases von PHPUnit 10, 11, 12 und 13. In dieser Zeit habe ich sie umformatiert, in eine neue Datei verschoben, an einen umbenannten Import angepasst und für eine strengere Stufe der statischen Analyse abgesichert. Als ich sie am 6. Oktober 2026 gelöscht habe, kosteten sie mich noch zwei Merge-Konflikte.
Gefunden hat sie PHPStan 2.3 am Tag seines Erscheinens, mit einer neuen Prüfung auf Zuweisungen, deren Wert niemand liest. Ich lasse diese Prüfung eingeschaltet, obwohl jedes Update neue Befunde bringen kann. Sie zeigt allerdings nur auf das Ende einer toten Kette. Was darüber hinaus tot war, habe ich erst gefunden, als ich den Code und seine Geschichte gelesen habe.
Erst starb der Wert, dann die Variable
Die Zeilen hatten einmal zwei Aufgaben. Die ältere stammt vom Januar 2011. Damals brach eine ungültige @covers-Annotation den ganzen Testlauf ab, und Issue #63 verlangte stattdessen einen Fehler für den betroffenen Test. Ab Version 3.6 fing PHPUnit deshalb die Exception der Code Coverage-Bibliothek und machte sie zur Exception des Tests, sofern der Test nicht schon selbst eine geworfen hatte. 2018 wurde daraus die Zeile $e = $e ?? $cce;.
Die jüngere Aufgabe stammt vom Juni 2016. Seit PHPUnit 5.5 gilt ein fehlgeschlagenes assert() im getesteten Code als Fehlschlag des Tests und nicht mehr als Fehler. Dafür musste aus dem AssertionError von PHP ein AssertionFailedError von PHPUnit werden, denn nur so eine Exception nahm TestResult::addFailure() an. Weil das assert() im getesteten Code steht und nicht im Test, habe ich Datei und Zeile an die Meldung angehängt. Ohne diese Angabe hätte die Meldung nicht verraten, wo das fehlgeschlagene assert() steht. Bis PHPUnit 9 hat der folgende Code den Wert von $e verwendet:
if ($error) {
$this->addError($test, $e, $time);
} elseif ($failure) {
$this->addFailure($test, $e, $time);
} elseif ($warning) {
$this->addWarning($test, $e, $time);
}
In PHPUnit 10 werden Testergebnisse nicht mehr in einem TestResult-Objekt gesammelt, sondern aus Events abgeleitet. TestCase::runBare() fängt einen AssertionError selbst, meldet die ursprüngliche Exception mit dem Event Test\Failed und wirft sie weiter. Der catch-Block in TestRunner::run() wird also weiterhin erreicht. Er muss den Test auch weiterhin als fehlgeschlagen markieren und das assert() als Assertion zählen. Die Meldung, die er zusammenbaut, sieht aber niemand mehr. Seit dem 7. August 2022 erwartet der End-to-End-Test für diesen Fall die ursprüngliche Meldung und darunter die Position aus dem Stack Trace.
Am 8. August 2022 habe ich Framework\TestResult gelöscht. Mit der Klasse verschwanden addError() und addFailure(), die letzten Stellen, die den Wert von $e brauchten. Übrig blieb Code, der nur noch wissen wollte, ob $e überhaupt gesetzt ist. Aus „die Exception, die gemeldet werden soll“ war „es ist eine Exception aufgetreten“ geworden. In dieser Rolle hatte $e = $e ?? $cce; noch eine Wirkung: Nach einer Exception der Code Coverage-Bibliothek war $e dadurch gesetzt. Die Zuweisung von new AssertionFailedError(...) hatte keine Wirkung mehr, denn gesetzt war $e in diesem catch-Block ohnehin.
Am 22. April 2023 habe ich für #5340 ein vorzeitiges return entfernt. Seinetwegen wurden für fehlgeschlagene oder abgebrochene Tests nicht alle Events gemeldet. Mit ihm verschwand die letzte Stelle, die $e nach dem try/catch abfragte. Ab PHPUnit 10.1.2 waren beide Zuweisungen an $e tot. Nichts im Code markierte diese Wechsel. Die Variable hieß weiterhin $e, und die Zeilen, die ihr Werte zuwiesen, sahen aus wie immer.
Mit dem letzten Leser ging ein Signal verloren
Dass $e zuletzt nur noch „es ist eine Exception aufgetreten“ bedeutete, hatte eine Folge, die schneller sichtbar wurde als der tote Code. Vor #5340 kehrte run() zurück, sobald $e gesetzt war. Ein Test, der mit einem Fehler abgebrochen war, erreichte deshalb nie die Prüfung, ob er überhaupt eine Assertion ausgeführt hat. Nach #5340 erreichte er sie, und diese Prüfung fragte $error nicht ab. Von PHPUnit 10.1.2 bis 10.2.4 galt ein Test, der vor seiner ersten Assertion mit einem Fehler abbrach, deshalb zusätzlich als riskant.
Zu sehen war das schon im Commit für #5340. Er änderte die Erwartungen von 16 bestehenden End-to-End-Tests, und in einem davon wurde aus Risky: 1 ein Risky: 4. Das ist die Art von Änderung an bestehenden Tests, die ich in „Unveränderte Tests sind der halbe Beweis“ als Grund zum Innehalten beschrieben habe. Gemeldet wurde der Fehler im Juli 2023 als #5443. Behoben habe ich ihn am selben Tag mit einem !$error && in der Bedingung, ausgeliefert mit PHPUnit 10.2.5.
Analysiert PHPStan 2.3 den Stand von PHPUnit 10.1.2, meldet es neben $e = $e ?? $cce; auch die Zuweisung $error = true im Code Coverage-Block als nie gelesen. Diese tote Zuweisung ist ein Symptom des Fehlers, nicht der Fehler selbst. Sie sagt: Nach dieser Zeile fragt niemand mehr $error ab. Darum ging es in #5443.
Eine lebende Klasse schützt toten Code
Im August 2022 habe ich direkt neben diesen Zeilen zweimal toten Code gelöscht. Am 12. August verschwand catch (Warning $e) { $warning = true; } zusammen mit der Klasse Framework\Warning. Am 23. August verschwand $e = new ExceptionWrapper($e); zusammen mit der Klasse Framework\ExceptionWrapper. Diese Zuweisung war ein Dead Store derselben Art wie $e = new AssertionFailedError(...): ein Objekt, gebaut für einen Abnehmer, den es nicht mehr gab. Sie stand nur ein paar Zeilen weiter unten.
Gelöscht habe ich beide, weil ich ihre Klassen gelöscht habe. Wer eine Klasse löscht, muss jede Stelle anfassen, die sie verwendet, sonst meldet die statische Analyse einen Fehler. AssertionFailedError dagegen lebt bis heute: Jede fehlgeschlagene Assertion in PHPUnit wirft sie oder eine ihrer Unterklassen. Nichts hat mich gezwungen, mir die Stelle anzusehen, an der eine Instanz davon gebaut und gleich wieder vergessen wurde. Toter Code, der nur lebende Klassen verwendet, fällt beim Aufräumen nicht auf.
Zeilen ohne Wirkung brauchen trotzdem Pflege
Zwischen dem 22. April 2023 und dem 6. Oktober 2026 hatten die Zeilen keine Wirkung. Angefasst wurden sie trotzdem:
| Datum | Commit | Änderung |
|---|---|---|
| 27. Mai 2023 | 981b9116 |
Trailing Commas nach $frame['line'] und der schließenden Klammer, als Teil einer Umformatierung von 358 Dateien
|
| 6. Mai 2024 | 63425422 |
Umzug von src/Framework/TestRunner.php nach src/Framework/TestRunner/TestRunner.php
|
| 14. März 2025 | a250e5ae |
Ab PHPUnit 12.1 heißt der Import-Alias CodeCoverageException statt OriginalCodeCoverageException, damit ändert sich die catch-Klausel direkt über $e = $e ?? $cce;
|
| 7. April 2026 | 0dd6ba8b |
Ab PHPUnit 13.2 werden aus $frame = $e->getTrace()[0]; drei Anweisungen, darunter assert(isset($trace[0]));
|
| 19. September 2026 | 2621452b |
Ab PHPUnit 13.4 stehen die Zeilen in der Methode runInProcess()
|
Die Umformatierung kam von einer erweiterten Regel für Trailing Commas in der Konfiguration von PHP-CS-Fixer, die beim Mergen 358 Dateien auf einmal änderte. Der Umzug gehörte zur Vorarbeit für austauschbare Implementierungen der Prozessisolation. Die Umbenennung stammt aus einem Commit mit der Commit-Nachricht „Delete unused code“. Er löschte die ungenutzte Klasse Framework\CodeCoverageException, und damit war ihr Name frei für den Alias der Exception aus php-code-coverage. Den toten Code direkt darunter ließ er stehen. Im April 2026 schließlich habe ich $e->getTrace()[0] abgesichert, weil PHPStan auf Level 7 einen möglicherweise nicht vorhandenen Array-Index meldete. Ich habe also Code gegen einen Fehlerfall abgesichert, obwohl niemand sein Ergebnis verwendete.
Einen leisen Verdacht hatte ich jedes Mal, wenn ich an diesen Zeilen vorbeikam. Nachgegangen bin ich ihm nie. TestRunner::run() ist die Methode, durch die jeder Test läuft, und dort wollte ich nichts ohne guten Grund ändern. Ein Verdacht war mir dafür nicht genug. So blieb jede dieser Änderungen bei ihrem eigentlichen Zweck, und keine fragte, ob die Zeilen überhaupt noch gebraucht werden.
Selbst die Löschung kostete noch Arbeit. Ich habe sie in 11.5 committet und von dort nach 12.5, 13.4 und main gemergt. Die Merges nach 12.5 und 13.4 liefen in Konflikte, weil die toten Zeilen dort nicht mehr so aussahen wie in 11.5: in 12.5 wegen des umbenannten Alias, in 13.4 wegen der Absicherung vom April. Beide Konflikte musste ich von Hand auflösen.
PHPStan meldet nur das Ende einer Kette
PHPStan 2.3.0 erschien am 6. Oktober 2026. Um 8:49 Uhr habe ich in 11.5 das PHAR von 2.2.16 auf 2.3.0 aktualisiert. PHPUnit bindet seit November 2024 Bleeding Edge ein, also die Regeln und Verschärfungen, die PHPStan erst mit der nächsten Major Version für alle einschaltet. Markus Staab hat das damals beigetragen. Mit 2.2.16 meldete PHPStan für 11.5 keinen Fehler, mit 2.3.0 sechs. Ohne Bleeding Edge wäre es nur einer gewesen.
Die anderen fünf stammen aus der neuen Erkennung ungenutzter Variablen, die PHPStan 2.3 mitbringt. Sie geht die Anweisungen eines Funktionsrumpfs rückwärts durch und prüft für jede Zuweisung, ob irgendein Pfad ihren Wert liest, bevor er überschrieben wird oder die Funktion endet. Compiler machen diese Lebendigkeitsanalyse seit Jahrzehnten, um überflüssige Zuweisungen zu entfernen. Eine Zuweisung, deren Wert auf keinem Pfad gelesen wird, heißt dort Dead Store.
Auch der Optimizer von PHP beseitigt toten Code, das habe ich in „Wie PHP Bytecode optimiert“ gezeigt. An diese Zeilen ist er nie herangekommen. TestRunner::run() enthält try und catch, und für Funktionen mit Exception-Handlern lässt der Optimizer seit PHP 7.1 alle Passes aus, die auf der SSA-Form aufbauen; im Quelltext steht an dieser Stelle ein „TODO“. Aber auch ohne try hätte er die Zuweisungen stehen lassen: Ein Objekt, das einen Destruktor haben könnte, lässt er nicht früher sterben, als der Code es vorsieht, und einen Konstruktor ruft er in jedem Fall auf. Auf der Kommandozeile läuft der Optimizer ohnehin nur, wenn jemand opcache.enable_cli einschaltet. PHP hat den toten Code also bei jedem fehlgeschlagenen assert() ausgeführt: Es hat ein neues Exception-Objekt mit eigenem Stack Trace gebaut und gleich wieder weggeworfen.
In TestRunner::run() meldete PHPStan nur $e = $e ?? $cce;, mit der Meldung Value assigned to variable $e is never read. Die Zuweisung von new AssertionFailedError(...) meldete es nicht, denn ihr Wert wird gelesen: von $e ?? $cce. Dass dieser Lesezugriff selbst nur in einen toten Wert fließt, erkennt PHPStan, meldet aber bewusst nur das Ende der Kette. Ein Kommentar im Quelltext von PHPStan begründet das damit, dass dieser eine Befund schon auf die ganze Kette zeigt.
Löschst du nur die gemeldete Zeile, meldet ein zweiter Lauf die Zuweisung von new AssertionFailedError(...). Löschst du auch die, meldet PHPStan nichts mehr. $frame wird nämlich noch gelesen, von assert(isset($frame['file'])) und assert(isset($frame['line'])), und ein Funktionsaufruf zählt als Verwendung. Diese beiden Zeilen habe ich im Mai 2022 mit der Commit-Nachricht „Be paranoid“ geschrieben, weil Psalm einen möglicherweise nicht vorhandenen Array-Index bemängelt hatte. Assertions, die ich 2022 für ein Werkzeug zur statischen Analyse geschrieben habe, haben 2026 den Rest des toten Codes vor einem anderen versteckt.
Auch die Variablen in den drei catch-Klauseln meldet PHPStan nicht, obwohl sie danach niemand mehr liest. Wer eine solche Variable weglässt, ändert die Syntax, denn seit PHP 8.0 darf eine catch-Klausel ohne Variable auskommen. Diese Entscheidung überlässt PHPStan bewusst dem Menschen.
Dass die Zeilen seit der Löschung von Framework\TestResult tot waren, habe ich mit git blame und git log herausgefunden, nicht mit PHPStan. Danach habe ich auch $frame mit seinen beiden Assertions, den Import von assert() und die drei catch-Variablen gelöscht:
} catch (AssertionFailedError $e) {
$failure = true;
if ($e instanceof IncompleteTestError) {
$incomplete = true;
} elseif ($e instanceof SkippedTest) {
$skipped = true;
}
-} catch (AssertionError $e) {
+} catch (AssertionError) {
$test->addToAssertionCount(1);
$failure = true;
- $frame = $e->getTrace()[0];
-
- assert(isset($frame['file']));
- assert(isset($frame['line']));
-
- $e = new AssertionFailedError(
- sprintf(
- '%s in %s:%s',
- $e->getMessage(),
- $frame['file'],
- $frame['line'],
- ),
- );
-} catch (Throwable $e) {
+} catch (Throwable) {
$error = true;
}
// ...
- } catch (OriginalCodeCoverageException $cce) {
+ } catch (OriginalCodeCoverageException) {
$error = true;
-
- $e = $e ?? $cce;
}
Was bleibt, hat eine Wirkung: $failure = true, $error = true und die zusätzliche Assertion, die für ein fehlgeschlagenes assert() gezählt wird. In 11.5 entfernt der Commit 19 Zeilen und fügt 3 hinzu. Ausgeliefert wurde die Löschung zuerst am 12. Oktober 2026 mit PHPUnit 12.5.39 und PHPUnit 13.4.2.
Die anderen fünf Befunde des Updates waren kleiner. Zwei foreach-Schleifen in TestCase stellten Error- und Exception-Handler wieder her und iterierten dabei über Werte, die sie nie lasen (foreach.valueOverwritten). Heute rufen zwei for-Schleifen restore_error_handler() und restore_exception_handler() so oft auf, wie Handler aktiv sind. Zweimal wies der Code einer Variable einen Wert zu, den sie schon hatte (assign.redundant). Und einmal fragte isset() eine Variable ab, die immer existiert und nie null ist (isset.variable). Um 9:01 Uhr, zwölf Minuten nach dem Update, waren alle sechs Befunde mit fünf Commits behoben.
Die Coverage hielt den toten Code für getestet
Code Coverage hätte nicht geholfen. Den End-to-End-Test, den ich 2016 zusammen mit der Umwandlung von AssertionError in AssertionFailedError geschrieben habe, gibt es bis heute. Er führt jede ausführbare Zeile des catch-Blocks aus, die tote Zuweisung eingeschlossen. Im Code Coverage-Bericht für diesen einen Test ist der Block vollständig grün.
Seit August 2022 prüft der Test aber eine Meldung, die PHPUnit an anderer Stelle erzeugt. Line Coverage misst, ob eine Zeile ausgeführt wurde, nicht, ob ihr Ergebnis irgendwo ankommt. Ein Test, der Code ausführt, dessen Ergebnis er nicht prüft, lässt diesen Code getestet aussehen. Was Code Coverage misst und was nicht, habe ich in „Path Coverage oder Mutation Testing?“ ausführlicher beschrieben.
Ich lasse die Prüfung eingeschaltet
Eine vergleichbare Prüfung hatte PHPUnit schon einmal. Von 2019 bis 2024 habe ich PHPUnit mit Psalm analysiert, und im Juli 2020 habe ich dort die Option findUnusedVariablesAndParams eingeschaltet. Im November 2020 habe ich sie in einem Commit mit der Commit-Nachricht „Tweak“ wieder ausgeschaltet. Warum, weiß ich nicht mehr. Psalm 6.20 meldet mit dieser Option für den Code vor der Löschung beide toten Zuweisungen in einem einzigen Lauf.
Prüfungen auf ungenutzte Variablen haben einen Preis. Sie melden auch Zuweisungen, die Absicht sind. Mit Bleeding Edge kann jedes Update von PHPStan neue Befunde bringen, die vor dem nächsten grünen Build behoben oder in die Baseline eingetragen werden müssen. Wie viele das sind, hängt von der Codebasis ab; bei PHPUnit waren es sechs. Für eine Zuweisung, die du absichtlich behalten willst, gibt es einen Ausweg: Variablen, deren Name mit einem Unterstrich beginnt, ignoriert PHPStan.
Ich lasse die Prüfung trotzdem eingeschaltet. Ihre Befunde zeigen allerdings nur, wo das Lesen anfängt. PHPStan hat auf zwei Zuweisungen gezeigt. Seit wann sie tot waren und welche Zeilen noch dazugehörten, habe ich selbst herausgefunden. Ob sich das in deinem Projekt lohnt, hängt davon ab, wie viele Befunde ein Update bringt und ob jemand Zeit hat, ihnen am selben Tag nachzugehen. Bei PHPUnit standen zwölf Minuten an einem Dienstagmorgen gegen dreieinhalb Jahre, in denen ich Zeilen ohne Wirkung mitgepflegt habe, und gegen einen Regressionsfehler, auf den eine Prüfung auf tote Zuweisungen gezeigt hätte.