Risky Tests in PHPUnit: Warnsignale erkennen und systematisch beheben
AI generated
@test
assert
PHPUnit · Testqualitaet · PHP
Risky Tests in PHPUnit
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.

15 Min. Lesezeit Risky Tests · Assertions · Testqualitaet PHPUnit 10 · 11 · PHP 8.x

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.

11. FAQ: Risky Tests in PHPUnit: Das Wichtigste auf einen Blick

1Fuehrt ein risky Test dazu, dass PHPUnit mit einem Fehler abbricht?
Standardmaessig nein. Ein risky Test wird separat gezaehlt und in der Zusammenfassung ausgewiesen, beeinflusst aber nicht automatisch den Exit-Code von PHPUnit. Wer das erzwingen will, muss den JUnit-Report auswerten oder failOnRisky in der Konfiguration aktivieren.
2Was ist der Unterschied zwischen einem risky Test und einem geskippten Test?
Ein geskippter Test wurde bewusst uebersprungen, etwa weil eine Voraussetzung fehlt, und liefert kein Ergebnis. Ein risky Test lief vollstaendig durch und wurde formal nicht als fehlgeschlagen gewertet, zeigt aber ein Verhalten, das PHPUnit als problematisch einstuft.
3Muss ich expectNotToPerformAssertions in jedem Test ohne Assertion verwenden?
Nein, meistens ist das ein Signal, dass eine echte Assertion fehlt und ergaenzt werden sollte. expectNotToPerformAssertions ist nur dann sinnvoll, wenn das Fehlen einer Exception tatsaechlich die gesamte Testabsicht ist, etwa bei einem reinen Smoke-Test.
4Warum stoert PHPUnit sich an Output waehrend eines Tests?
Weil Tests ihre Ergebnisse ausschliesslich ueber Assertions und Exceptions kommunizieren sollen. Jeder Output umgeht diesen Mechanismus, verschmutzt die Testausgabe und ist meist ein Zeichen fuer vergessenen Debug-Code oder unsauberen Produktivcode.
5Kann ein Test wegen globalem Zustand risky sein, obwohl er isoliert erfolgreich laeuft?
Ja. Die risky-Markierung entsteht bereits dadurch, dass sich globaler Zustand zwischen Testbeginn und Testende geaendert hat, unabhaengig davon, ob dieser Test selbst dadurch beeintraechtigt wird. Das Risiko betrifft vor allem andere, spaeter laufende Tests.
6Ist @runInSeparateProcess eine gute Loesung fuer risky Tests mit globalem Zustand?
Es unterdrueckt die Symptome, weil jeder Test in einem frischen Prozess startet, kostet aber deutlich Laufzeit und behebt nicht die eigentliche Ursache. Besser ist, den globalen Zustand durch injizierbare Abhaengigkeiten zu ersetzen.
7Sollte ich alle Strict-Schalter sofort in einem grossen Altprojekt aktivieren?
Das kann eine grosse Zahl an Warnungen auf einmal produzieren und das Projekt praktisch blockieren. Sinnvoller ist ein schrittweises Vorgehen: Erkennung aktivieren, Ausgangswert dokumentieren, dann kontinuierlich abbauen.
8Wie finde ich heraus, welche konkreten Tests als risky markiert wurden?
Die Konsolen-Ausgabe von PHPUnit listet risky Tests am Ende des Laufs mit Namen und Grund auf. Zusaetzlich enthaelt der JUnit-XML-Report entsprechende Markierungen, die sich fuer eine automatisierte Auswertung eignen.
9Zaehlt ein Test mit Warning auch als risky?
Nein, PHP Warnings oder Deprecations, die waehrend eines Tests auftreten, werden separat als Warnungen oder Deprecations gezaehlt. Risky bezieht sich spezifisch auf die drei beschriebenen Kategorien: fehlende Assertion, Output, globaler Zustand.
10Lohnt sich ein hartes CI-Gate fuer risky Tests in jedem Projekt?
In neuen Projekten fast immer, weil der Aufwand gering ist und die Warnungen von Anfang an ernst genommen werden. In grossen Altprojekten empfiehlt sich zunaechst ein weicher Schwellenwert, der schrittweise verschaerft wird, statt ein sofortiger harter Stopp.