Warum PHPUnit manche Tests als riskant einstuft und wie man die Ursachen behebt
PHPUnit markiert Tests nicht nur als bestanden oder fehlgeschlagen, sondern gelegentlich auch als risky. Das ist kein kosmetisches Detail, sondern ein Hinweis auf Tests, die zwar gruen sind, aber nichts aussagen oder sich schlecht verhalten: keine Assertion, unerwarteter Output oder heimliche Aenderungen am globalen Zustand. Wer diese Warnungen ignoriert, sammelt sich einen Testbestand an, der Vertrauen vortaeuscht, das er nicht verdient.
Inhaltsverzeichnis
- 1. Was PHPUnit unter risky versteht
- 2. Tests ohne Assertion: Der haeufigste Fall
- 3. Unerwarteter Output waehrend des Testlaufs
- 4. Unerwartete Aenderungen am globalen Zustand
- 5. Risky-Erkennung ueber phpunit.xml steuern
- 6. Risky Tests in der CI-Pipeline sichtbar machen
- 7. Systematisch vorgehen statt Symptome unterdruecken
- 8. Haeufige Fallstricke bei der Behebung
- 9. Team-Richtlinien fuer risky Tests
- 10. Zusammenfassung
- 11. FAQ
1. Was PHPUnit unter risky versteht
Ein Test in PHPUnit gilt normalerweise als bestanden, wenn keine Exception fliegt und alle Assertions erfuellt sind. Zusaetzlich zu diesem binaeren Ergebnis fuehrt PHPUnit seit vielen Versionen eine dritte Kategorie: risky. Ein risky Test ist formal nicht fehlgeschlagen, verhaelt sich aber so, dass PHPUnit ihm nicht traut. Die haeufigsten Gruende sind ein Test ohne eine einzige Assertion, ein Test, der waehrend seiner Ausfuehrung Ausgaben erzeugt, oder ein Test, der globalen Zustand veraendert, ohne ihn wiederherzustellen.
Der Unterschied zu einem klassischen Fehlschlag ist wichtig: Ein risky Test kann in der Standardkonfiguration trotzdem als erfolgreich durchgehen, taucht aber in der Zusammenfassung mit einem eigenen Zaehler auf. Wer die Ausgabe von PHPUnit nur oberflaechlich liest, uebersieht diese Warnungen leicht, weil die Testsuite insgesamt gruen bleibt. Genau das macht risky Tests gefaehrlich: Sie schleichen sich unbemerkt in eine Codebasis ein und wachsen mit der Zeit zu einem echten Vertrauensproblem heran, weil niemand mehr genau weiss, welche Tests wirklich etwas pruefen.
2. Tests ohne Assertion: Der haeufigste Fall
Der klassische Fall eines risky Tests ist eine Testmethode, die zwar Code ausfuehrt, aber keine einzige assert-Methode aufruft. Das passiert oft aus Bequemlichkeit: Ein Entwickler ruft eine Methode auf, sieht in der IDE, dass keine Exception geworfen wird, und haelt den Test fuer erledigt. Formal betrachtet hat dieser Test aber nichts geprueft. Er waere auch dann gruen, wenn die getestete Methode ploetzlich etwas voellig anderes zurueckgibt, solange sie keine Exception wirft.
PHPUnit erkennt diesen Fall automatisch und markiert die Methode als risky mit dem Hinweis, dass keine Assertions ausgefuehrt wurden. Die Behebung ist meist einfach: Man ergaenzt eine konkrete Erwartung, etwa mit assertSame fuer den Rueckgabewert oder assertInstanceOf fuer den erzeugten Objekttyp. Bei Tests, die absichtlich nur pruefen sollen, dass keine Exception auftritt, bietet sich stattdessen expectNotToPerformAssertions an. Diese Methode dokumentiert die Absicht explizit und unterdrueckt die risky-Warnung, ohne eine sinnlose Dummy-Assertion einzufuegen.
final class InvoiceExporterTest extends \PHPUnit\Framework\TestCase
{
// Risky: keine Assertion, der Test prueft praktisch nichts.
public function testExportRunsWithoutError(): void
{
$exporter = new InvoiceExporter();
$exporter->export(new Invoice('INV-1001'));
}
// Korrekt: konkrete Erwartung an das Ergebnis.
public function testExportReturnsGeneratedFilePath(): void
{
$exporter = new InvoiceExporter();
$path = $exporter->export(new Invoice('INV-1001'));
self::assertStringEndsWith('INV-1001.pdf', $path);
}
// Wenn wirklich nur "keine Exception" geprueft werden soll: explizit machen.
public function testExportDoesNotThrowForEmptyInvoice(): void
{
$exporter = new InvoiceExporter();
$exporter->export(new Invoice(''));
$this->expectNotToPerformAssertions();
}
}
3. Unerwarteter Output waehrend des Testlaufs
Ein zweiter haeufiger Grund fuer die risky-Markierung ist Output, den ein Test waehrend seiner Ausfuehrung erzeugt: ein vergessenes var_dump, ein echo zu Debugzwecken oder eine Bibliothek, die Warnungen direkt auf die Standardausgabe schreibt. PHPUnit erwartet, dass Tests still laufen und ihre Ergebnisse ausschliesslich ueber Assertions und Exceptions kommunizieren. Jede Ausgabe, die diesen Kanal umgeht, gilt als Verstoss gegen dieses Prinzip, selbst wenn der Inhalt der Ausgabe harmlos ist.
In der Praxis stammt dieser Output meist aus Debug-Resten, die vor dem Commit vergessen wurden, oder aus Legacy-Code, der direkt auf STDOUT schreibt, statt einen Logger zu verwenden. Die Behebung besteht entweder darin, den Debug-Code zu entfernen, oder, falls die getestete Methode absichtlich Ausgaben erzeugt, den Output gezielt abzufangen und selbst zu pruefen. Dafuer eignet sich das expectOutputString-Verfahren, mit dem der erwartete Output Teil der Assertion wird, statt ein Nebeneffekt zu bleiben, den PHPUnit als Warnsignal einstuft.
final class ReportPrinterTest extends \PHPUnit\Framework\TestCase
{
public function testPrintSummaryOutputsExpectedText(): void
{
$printer = new ReportPrinter();
// Der Output wird als Assertion erfasst, nicht als Nebeneffekt.
$this->expectOutputString("Summary: 3 items processed\n");
$printer->printSummary(3);
}
}
final class ReportPrinter
{
public function printSummary(int $count): void
{
echo "Summary: {$count} items processed\n";
}
}
4. Unerwartete Aenderungen am globalen Zustand
Die dritte grosse Kategorie betrifft Tests, die globalen Zustand veraendern, etwa statische Klassenattribute, superglobale Arrays wie $_SERVER oder $_ENV, oder Singleton-Instanzen, und diesen Zustand nicht wieder zuruecksetzen. PHPUnit kann in strikten Konfigurationen erkennen, wenn sich globale Variablen zwischen dem Start und dem Ende eines Tests aendern, und markiert den Test dann als risky, weil er potenziell andere, spaeter laufende Tests beeinflusst.
Solche Tests sind besonders tueckisch, weil ihr Fehlverhalten oft erst in Kombination mit anderen Tests sichtbar wird: Ein Test A veraendert eine statische Property, Test B verlaesst sich unbewusst auf deren Ausgangswert und schlaegt fehl, aber nur, wenn A vorher lief. Die nachhaltige Loesung ist, jeden veraenderten globalen Zustand in tearDown wieder zurueckzusetzen, und wo moeglich, auf globalen Zustand ganz zu verzichten, indem Abhaengigkeiten explizit injiziert statt implizit ueber Singletons oder Superglobale bezogen werden.
final class FeatureFlagTest extends \PHPUnit\Framework\TestCase
{
private ?string $originalEnvValue;
protected function setUp(): void
{
$this->originalEnvValue = $_ENV['FEATURE_NEW_CHECKOUT'] ?? null;
}
protected function tearDown(): void
{
// Globalen Zustand exakt in den Ausgangszustand zuruecksetzen.
if ($this->originalEnvValue === null) {
unset($_ENV['FEATURE_NEW_CHECKOUT']);
} else {
$_ENV['FEATURE_NEW_CHECKOUT'] = $this->originalEnvValue;
}
}
public function testFeatureIsEnabledWhenFlagIsSet(): void
{
$_ENV['FEATURE_NEW_CHECKOUT'] = '1';
self::assertTrue(FeatureFlag::isEnabled('FEATURE_NEW_CHECKOUT'));
}
}
5. Risky-Erkennung ueber phpunit.xml steuern
Wie streng PHPUnit bei der Erkennung riskanter Tests vorgeht, laesst sich in der phpunit.xml konfigurieren. Optionen wie beStrictAboutTestsThatDoNotTestAnything, beStrictAboutOutputDuringTests und beStrictAboutChangesToGlobalState steuern jeweils einzeln, ob die entsprechende Kategorie ueberhaupt geprueft wird. In vielen aelteren Projekten sind diese Schalter nicht aktiviert, sodass sich risky Tests unbemerkt ansammeln koennen, ohne dass die Team-Mitglieder es merken.
Fuer neue Projekte empfiehlt es sich, alle drei Schalter von Anfang an zu aktivieren, damit riskantes Verhalten sofort auffaellt statt sich erst Monate spaeter zu raechen. Bei bestehenden Projekten mit vielen Altlasten ist ein schrittweises Vorgehen sinnvoller: Zunaechst die Erkennung aktivieren, die aktuelle Anzahl risky Tests als Ausgangswert dokumentieren, und dann kontinuierlich abbauen, statt alle Verstoesse auf einmal beheben zu muessen, was ein grosses Projekt schnell blockieren wuerde.
<!-- phpunit.xml -->
<phpunit bootstrap="vendor/autoload.php"
beStrictAboutTestsThatDoNotTestAnything="true"
beStrictAboutOutputDuringTests="true"
beStrictAboutChangesToGlobalState="true"
beStrictAboutTodoAnnotatedTests="true">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
</phpunit>
6. Risky Tests in der CI-Pipeline sichtbar machen
Eine strikte Konfiguration allein reicht nicht, wenn niemand die Ausgabe genau liest. Viele CI-Pipelines werten nur den Exit-Code von PHPUnit aus, und risky Tests fuehren standardmaessig nicht zu einem Exit-Code ungleich null. Ein Team kann monatelang risky Tests anhaeufen, ohne dass die Pipeline jemals rot wird, weil formal alle Tests bestanden haben.
Um das zu verhindern, lohnt es sich, den JUnit-XML-Report von PHPUnit in der Pipeline auszuwerten und die Anzahl der risky-markierten Tests explizit zu zaehlen. Ueberschreitet diese Zahl einen festgelegten Schwellenwert, oder steigt sie gegenueber dem letzten Lauf, kann die Pipeline gezielt fehlschlagen. So wird aus einer stillen Warnung ein hartes Gate, das verhindert, dass sich das Problem unbemerkt weiter ausbreitet, waehrend gleichzeitig genug Spielraum bleibt, um bestehende Altlasten schrittweise abzubauen.
7. Systematisch vorgehen statt Symptome unterdruecken
Wer mit einer grossen Zahl risky Tests konfrontiert ist, sollte nicht versuchen, die Warnungen einzeln und beliebig abzuarbeiten, sondern zunaechst eine Bestandsaufnahme machen. Ein sinnvoller erster Schritt ist, die drei Kategorien getrennt auszuzaehlen: Wie viele Tests haben keine Assertion, wie viele erzeugen Output, wie viele veraendern globalen Zustand. Diese Aufteilung zeigt oft schon, wo die groessten strukturellen Probleme liegen, etwa wenn ein bestimmtes Modul systematisch auf statische Singletons setzt.
Danach lohnt es sich, mit der Kategorie zu beginnen, die am einfachsten und risikoaermsten zu beheben ist, in der Regel Tests ohne Assertion, weil dort meist nur eine fehlende Erwartung ergaenzt werden muss. Globale Zustandsprobleme sind oft die aufwendigsten, weil sie tiefer liegende Architekturprobleme wie fehlende Dependency Injection widerspiegeln. Ein Team, das diese Reihenfolge einhaelt, sieht schnelle erste Erfolge und behaelt die Motivation, auch die schwierigeren Faelle anzugehen.
8. Haeufige Fallstricke bei der Behebung
Ein verbreiteter Fehler ist, eine risky-Warnung durch das blosse Hinzufuegen einer trivialen Assertion wie assertTrue(true) zum Verschwinden zu bringen, ohne dass der Test dadurch tatsaechlich etwas prueft. Das behebt formal die Warnung, verschleiert aber weiterhin, dass der Test keine echte Aussagekraft hat. Wer diesen Weg waehlt, verschiebt das eigentliche Problem nur, statt es zu loesen, und erschwert es kuenftigen Entwicklern, echte von vorgetaeuschten Tests zu unterscheiden.
Ein zweiter Fallstrick ist der Griff zu @runInSeparateProcess als schnelle Loesung fuer Probleme mit globalem Zustand. Das kann die Symptome unterdruecken, weil jeder Test in einem frischen Prozess laeuft, kostet aber deutlich Laufzeit und loest nicht das eigentliche Architekturproblem. Besser ist es, den globalen Zustand durch injizierbare Abhaengigkeiten zu ersetzen. Prozessisolierung sollte die Ausnahme fuer wirklich unvermeidbare Faelle bleiben, nicht die Standardantwort auf jede risky-Warnung im Bereich globaler Zustand.
9. Team-Richtlinien fuer risky Tests
Damit die Erkenntnisse aus diesem Artikel im Alltag wirken, braucht ein Team klare, schriftlich festgehaltene Richtlinien: Wann sind alle drei Strict-Schalter Pflicht in neuen Projekten, wie wird mit Altlasten in bestehenden Projekten umgegangen, und wer ist verantwortlich, wenn die Zahl risky Tests in der CI steigt. Ohne diese Vereinbarung bleibt die Behebung vom Einzelfall abhaengig, und die Zahl risky Tests wird ueber die Zeit eher wachsen als schrumpfen.
Ein bewaehrtes Vorgehen ist, risky Tests in Code-Reviews explizit zu thematisieren, sobald ein Pull Request neue Warnungen einfuehrt, und regelmaessig, etwa monatlich, die Gesamtzahl im Team zu besprechen. Die folgende Tabelle fasst die drei Kategorien, ihre typischen Ursachen und die jeweils passende Behebung zusammen, als schnelle Referenz fuer die taegliche Arbeit.
| Kategorie | Typische Ursache | Erkennung ueber | Empfohlene Behebung |
|---|---|---|---|
| Keine Assertion | Test prueft nur, dass keine Exception fliegt | beStrictAboutTestsThatDoNotTestAnything | Konkrete assert-Methode ergaenzen oder expectNotToPerformAssertions |
| Output waehrend Test | Vergessenes var_dump/echo, Legacy-Ausgaben | beStrictAboutOutputDuringTests | Debug-Code entfernen oder expectOutputString nutzen |
| Globaler Zustand veraendert | Statische Properties, Superglobale, Singletons | beStrictAboutChangesToGlobalState | In tearDown zuruecksetzen, Dependency Injection statt Singleton |
| Todo-Annotation | Test bewusst als unvollstaendig markiert | beStrictAboutTodoAnnotatedTests | Test fertigstellen oder Ticket verlinken und bewusst tracken |
Mironsoft
Testautomatisierung, Magento-Qualitätssicherung und CI-Integration
Tests, die echte Fehler finden statt nur grün zu leuchten?
Wir prüfen bestehende PHPUnit-Suiten auf Implementierungsdetail-Tests, flaky Tests und fehlende Coverage an kritischen Stellen und bauen daraus eine Teststrategie, die bei jedem Magento-Update wirklich Sicherheit gibt.
Test-Audit
Bestehende Suiten auf Mocking-Antipatterns und blinde Flecken prüfen.
Teststrategie
Unit-, Integrations- und MFTF-Tests sinnvoll für Magento-Projekte kombinieren.
CI-Integration
Schnelle, zuverlässige Testläufe in GitLab CI oder GitHub Actions einrichten.
10. Zusammenfassung
Risky Tests in PHPUnit: Das Wichtigste auf einen Blick
Definition
Ein risky Test ist formal nicht fehlgeschlagen, verhaelt sich aber so, dass PHPUnit ihm nicht traut: keine Assertion, unerwarteter Output oder veraenderter globaler Zustand.
Konfiguration
Die drei beStrictAbout-Schalter in phpunit.xml aktivieren die jeweilige Erkennung und sollten in neuen Projekten von Anfang an eingeschaltet sein.
Haeufigster Fallstrick
Eine triviale Dummy-Assertion beseitigt die Warnung, aber nicht das eigentliche Problem: der Test bleibt aussagelos.
CI-Gate
Ohne explizite Auswertung des JUnit-Reports bleibt der Exit-Code trotz risky Tests gruen, das Problem waechst unbemerkt.