@test
assert
PHPUnit · Flaky Tests · CI/CD · Test-Stabilität
Flaky Tests erkennen und
systematisch beseitigen

Ein Test, der manchmal besteht und manchmal nicht, ist schlimmer als gar kein Test. Er untergräbt das Vertrauen in die gesamte Testsuite, erzwingt manuelle Re-Runs und versteckt echte Regressionen hinter rauschen. Flaky Tests entstehen aus spezifischen Ursachen – und alle lassen sich dauerhaft beheben.

17 Min. Lesezeit Zeitabhängigkeiten · Race Conditions · DB-Zustand · Zufallswerte PHPUnit 10/11 · PHP 8.4 · CI/CD

1. Was macht einen Test flaky?

Ein Flaky Test ist ein Test, dessen Ergebnis nicht deterministisch ist – er schlägt in identischer Codebase und identischer Umgebung gelegentlich fehl, ohne dass eine Änderung am Produktionscode stattgefunden hat. Das Wort "gelegentlich" ist entscheidend: Ein Test, der immer fehlschlägt, ist ein einfach zu reparierender Fehler. Ein Test, der in 5% der Läufe fehlschlägt, ist ein schleichendes Problem, das das Vertrauen in die gesamte Testsuite erodiert.

Die häufigsten Ursachen für Flaky Tests in PHP-Projekten sind: Zeitabhängigkeiten (Tests, die mit date() oder time() arbeiten, ohne eine kontrollierbare Uhr zu verwenden), gemeinsamer Datenbankzustand zwischen Tests (ein Test hinterlässt Daten, die den nächsten Test beeinflussen), Zufallsquellen wie random_int() oder array_rand() ohne festen Seed, Abhängigkeiten von externen HTTP-APIs und Race Conditions in parallelen Testläufen. Fast jede dieser Ursachen hat ein klares Lösungsmuster.

Der gesellschaftliche Schaden von Flaky Tests ist größer als der direkte technische. Teams, die regelmäßig fehlschlagende Tests mit "das ist halt flaky" abtun und den CI-Job neu starten, gewöhnen sich daran, roten CI-Output zu ignorieren. Das ist der Moment, in dem echte Regressionen unbemerkt durchrutschen. Flaky Tests müssen deshalb konsequent als Bugs behandelt und priorisiert behoben werden – nicht toleriert.

2. Flaky Tests systematisch erkennen und dokumentieren

Der erste Schritt zur Beseitigung von Flaky Tests ist ihre zuverlässige Identifikation. Ein einzelner fehlgeschlagener CI-Lauf kann viele Ursachen haben – Infrastrukturprobleme, Netzwerkausfall, erschöpfte Datei-Descriptoren. Erst wenn derselbe Test in mehreren unabhängigen Läufen bei identischem Commit fehlschlägt, ist Flakiness die wahrscheinlichste Ursache. Tools wie die Re-Run-Funktion von PHPUnit (--repeat) oder externe Tracking-Systeme helfen, Muster zu erkennen.

Für die systematische Identifikation empfiehlt sich ein dedizierter CI-Job, der die Testsuite mehrfach hintereinander in derselben Umgebung ausführt und die Ergebnisse vergleicht. Ein Test, der in 10 aufeinanderfolgenden Läufen mindestens einmal fehlschlägt und einmal besteht, ist definitiv flaky. PHPUnit bietet mit --order=random und einem festen Seed (--random-order-seed=12345) die Möglichkeit, Reihenfolgeabhängigkeiten systematisch aufzudecken.


<?php
// Flaky test detection: run suite multiple times and compare
// vendor/bin/phpunit --repeat=10 --log-junit results.xml

// Detect order-dependent flakiness:
// vendor/bin/phpunit --order=random --random-order-seed=42
// vendor/bin/phpunit --order=random --random-order-seed=99

// PHPUnit 10+ allows marking known flaky tests explicitly
// while they await repair (NOT a long-term solution):
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

final class ExampleFlakyTest extends TestCase
{
    // Temporary: document known flaky test so team is aware
    // Use #[Group('flaky')] to exclude from main CI run
    #[Test]
    #[\PHPUnit\Framework\Attributes\Group('flaky')]
    public function searchResultsAreOrderedCorrectly(): void
    {
        // This test is flaky due to non-deterministic sort order
        // from database. Tracked in issue #1234.
        // TODO: add ORDER BY clause or use collection sort assertion.
        $this->markTestSkipped('Known flaky: issue #1234');
    }
}

// CI: run normal suite without flaky group
// vendor/bin/phpunit --exclude-group flaky

3. Zeitabhängigkeiten kapseln: Clock-Interface und Fake-Uhren

Zeitabhängige Tests sind die häufigste Ursache für Flaky Tests in Geschäftslogik. Ein Test, der prüft, ob ein Coupon "heute" gültig ist, schlägt am 31.12. um Mitternacht möglicherweise fehl, weil der Test um 23:59 Uhr läuft und die Assertion um 00:01 Uhr ausgewertet wird. Tests, die Ablaufdaten, Buchungszeitstempel oder Caching-Timeouts prüfen, sind besonders anfällig.

Die Lösung ist ein Clock-Interface, das die aktuelle Zeit kapselt. Im Produktionscode gibt eine SystemClock-Implementierung die echte Systemzeit zurück. Im Test verwendet man eine FrozenClock, die immer denselben konfigurierten Zeitstempel zurückgibt – vollständig deterministisch, unabhängig von der tatsächlichen Ausführungszeit. Das Interface wird per Dependency Injection in alle Klassen injiziert, die zeitabhängige Logik enthalten.


<?php
declare(strict_types=1);

namespace Mironsoft\Common;

use DateTimeImmutable;

/** Clock interface for deterministic time in tests. */
interface ClockInterface
{
    public function now(): DateTimeImmutable;
}

/** Production implementation: returns real system time. */
final class SystemClock implements ClockInterface
{
    public function now(): DateTimeImmutable
    {
        return new DateTimeImmutable();
    }
}

/** Test implementation: always returns a fixed, configured point in time. */
final class FrozenClock implements ClockInterface
{
    public function __construct(private readonly DateTimeImmutable $frozenAt) {}

    public static function at(string $dateTime): self
    {
        return new self(new DateTimeImmutable($dateTime));
    }

    public function now(): DateTimeImmutable
    {
        return $this->frozenAt;
    }
}

// In production DI container:
// $clock = new SystemClock();

// In test — deterministic, no flakiness:
// $clock = FrozenClock::at('2026-05-10 12:00:00');
// $service = new CouponValidator($clock, $repository);
// $this->assertTrue($service->isValid($coupon));  // always same result

4. Datenbankzustand zwischen Tests isolieren

Geteilter Datenbankzustand ist die zweithäufigste Ursache für Flaky Tests in Integrationstests. Test A legt einen Datensatz an, Test B setzt voraus, dass die Tabelle leer ist – wenn Tests in der falschen Reihenfolge oder parallel laufen, schlägt Test B fehl. Das Problem ist oft unsichtbar, weil die Tests in der Standardreihenfolge zufällig korrekt laufen und erst bei Parallelisierung oder Reihenfolgeänderung brechen.

Drei Strategien zur Datenbankisolation: Erstens, Datenbank-Transaktionen, die nach jedem Test zurückgerollt werden (schnell, aber bei Tests, die selbst Transaktionen verwenden, problematisch). Zweitens, Datenbank-Truncation nach jedem Test (zuverlässig, aber langsamer). Drittens, für jeden Test eine frische In-Memory-Datenbank (SQLite) verwenden (sehr schnell, aber möglicherweise nicht kompatibel mit produktionsspezifischem SQL). Magento-Integrationstests verwenden Strategie eins automatisch.

5. Zufallsquellen kontrollieren und reproduzierbar machen

PHP-Funktionen wie random_int(), array_rand(), shuffle() und uniqid() erzeugen nicht-deterministische Werte. Tests, die auf die Ausgabe dieser Funktionen angewiesen sind, ohne den Seed zu kontrollieren, sind strukturell flaky. Die korrekte Lösung ist dieselbe wie bei Zeitabhängigkeiten: eine Abstraktion, die im Test durch eine kontrollierbare Implementierung ersetzt werden kann.

Ein RandomizerInterface mit Methoden wie int(int $min, int $max): int und string(int $length): string kann im Test durch eine vorhersagbare Sequenz ersetzt werden. Alternativ erlaubt PHP 8.2 die Random\Randomizer-Klasse mit konfigurierbarem Engine, sodass man im Test new Randomizer(new FixedSizeByteSource('...')) verwenden kann. Für einfache Fälle wie ID-Generierung reicht oft eine Fake-Implementierung, die einen Zähler zurückgibt.


<?php
declare(strict_types=1);

namespace Mironsoft\Common;

/** Randomizer interface for deterministic tests. */
interface RandomizerInterface
{
    /** @throws \ValueError if min > max */
    public function int(int $min, int $max): int;

    /** Returns a cryptographically random hex string of given byte length. */
    public function hexString(int $bytes): string;
}

/** Production implementation using PHP 8.2 Random\Randomizer. */
final class SecureRandomizer implements RandomizerInterface
{
    private readonly \Random\Randomizer $randomizer;

    public function __construct()
    {
        $this->randomizer = new \Random\Randomizer();
    }

    public function int(int $min, int $max): int
    {
        return $this->randomizer->getInt($min, $max);
    }

    public function hexString(int $bytes): string
    {
        return bin2hex($this->randomizer->getBytes($bytes));
    }
}

/** Test implementation: predictable sequence, no randomness. */
final class SequentialRandomizer implements RandomizerInterface
{
    private int $counter = 0;

    public function int(int $min, int $max): int
    {
        return $min + ($this->counter++ % ($max - $min + 1));
    }

    public function hexString(int $bytes): string
    {
        return str_pad((string)$this->counter++, $bytes * 2, '0', STR_PAD_LEFT);
    }
}

6. Externe Abhängigkeiten: HTTP, Queues und Dateisystem

Tests, die echte HTTP-Requests zu externen APIs machen, sind grundsätzlich flaky – Netzwerklatenz, API-Ratenlimiting, Serverausfälle und DNS-Timeouts sind außerhalb der Kontrolle der Testsuite. Die Lösung für Unit-Tests ist vollständiges Mocking des HTTP-Clients. Für Integrationstests gibt es HTTP-Recorder-Bibliotheken wie php-vcr/php-vcr, die echte HTTP-Requests beim ersten Lauf aufzeichnen und in folgenden Läufen aus der Aufzeichnung wiedergeben – deterministisch und ohne Netzwerk.

Dateiystem-Tests sind häufig flaky wegen fehlender Cleanup-Logik: Ein Test erzeugt eine Datei, ein anderer Test setzt voraus, dass sie nicht existiert. Die Lösung: setUp erstellt ein frisches temporäres Verzeichnis mit sys_get_temp_dir(), tearDown löscht es rekursiv. Nie feste Pfade in Tests verwenden. Queue-Tests erfordern eine testbare In-Memory-Queue-Implementierung, die keine echte Message-Broker-Verbindung braucht.

7. Test-Reihenfolge und versteckte Zustandsabhängigkeiten

Statische Variablen und Singletons sind häufige Quelle von Test-Reihenfolgeabhängigkeiten. Ein Test modifiziert einen statischen Zustand (Registry, Cache, Singleton), der nächste Test setzt voraus, dass dieser Zustand zurückgesetzt ist. Das funktioniert, solange Tests in der erwarteten Reihenfolge laufen – bei Parallelisierung oder --order=random brechen die Tests auseinander.

Die Lösung: In tearDown alle statischen Zustände zurücksetzen, die in setUp oder im Test selbst gesetzt wurden. Bei Magento-Tests betrifft das insbesondere den ObjectManager-Bootstrap und die Config-Singletons. PHPUnit bietet mit --order=random einen integrierten Weg, Reihenfolgeabhängigkeiten aufzudecken. Wer einen festen Seed verwendet, kann reproduzierbare Reihenfolgen erzeugen und eine gefundene Reihenfolge mit einem Issue-Tracker verknüpfen.

8. Flaky-Test-Ursachen im Überblick

Die folgende Tabelle zeigt die häufigsten Ursachen für Flaky Tests in PHP-Projekten, ihre Symptome und die empfohlene Lösung. Die meisten Ursachen haben ein klares, bewährtes Gegenmittel – das eigentliche Problem ist das fehlende Bewusstsein für die Ursache.

Ursache Symptom Lösung Schwierigkeit
Systemzeit (time(), date()) Schlägt kurz vor/nach Mitternacht fehl ClockInterface + FrozenClock Mittel
Geteilter DB-Zustand Schlägt bei Reihenfolgeänderung fehl Transaktions-Rollback oder Truncation Mittel
random_int(), shuffle() Schlägt bei bestimmten Zufallswerten fehl RandomizerInterface + Fake Niedrig
Externe HTTP-APIs Netzwerkfehler, Timeouts, Rate Limits Mocking oder VCR-Cassette Niedrig
Statische Variablen Schlägt nur bei bestimmter Test-Reihenfolge fehl tearDown reset + order=random Mittel

Die Schwierigkeit der Lösung hängt weniger von der technischen Komplexität ab als von der Durchdringungstiefe des Problems im Codebase. Ein time()-Aufruf in einer einzelnen Methode ist schnell durch ein ClockInterface ersetzt. Wenn jedoch zwanzig Klassen direkt time() aufrufen, ist die Migration zeitaufwändiger – aber die Alternative (dauerhaft flaky CI) ist teurer.

9. Zusammenfassung

Flaky Tests sind kein unvermeidliches Schicksal, sondern das Symptom von unkontrollierten Abhängigkeiten: Systemzeit, Datenbankzustand, Zufallsquellen, externe APIs und statische Variablen. Jede dieser Ursachen hat ein klares Lösungsmuster – ClockInterface für Zeit, Transaktions-Rollback für Datenbankzustand, Randomizer-Abstraktion für Zufallswerte, Mocking oder VCR für externe APIs, tearDown-Cleanup für statische Zustände.

Der erste Schritt ist konsequente Identifikation: --order=random und Mehrfach-Läufe decken versteckte Reihenfolgeabhängigkeiten auf. Bekannte Flaky Tests werden mit #[Group('flaky')] markiert und aus dem Haupt-CI-Lauf ausgeschlossen – aber nicht ignoriert. Jeder flaky Test erhält ein Issue und eine Priorität. Wer Flaky Tests toleriert, bezahlt mit dem Vertrauen seines Teams in die gesamte Testsuite – und dieses Vertrauen ist schwerer zurückzugewinnen als die Zeit, die die Reparatur kostet.

Flaky Tests beseitigen — Das Wichtigste auf einen Blick

Zeitabhängigkeiten

ClockInterface + FrozenClock per DI. Kein direktes time() oder date() in Produktionsklassen, die getestet werden sollen.

Datenbankisolation

Transaktions-Rollback nach jedem Test. Magento macht das automatisch. Für plain PHP: Doctrine DBAL oder eigene Transaction-Wrapper.

Reihenfolgeabhängigkeiten

--order=random aufdecken, statische Zustände in tearDown zurücksetzen, Singletons durch DI ersetzen.

Externe Abhängigkeiten

HTTP-Clients mocken. VCR-Cassetten für Integrationstests, die echte HTTP-Responses brauchen. Keine echten Netzwerkcalls in der Testsuite.

10. FAQ: Flaky Tests in PHPUnit erkennen und beseitigen

1Was ist ein Flaky Test?
Ein Test mit nicht-deterministischem Ergebnis – schlägt gelegentlich fehl ohne Codeänderung. Ursache ist immer eine unkontrollierte Abhängigkeit (Zeit, DB, Zufall, Netzwerk).
2Flaky Tests systematisch erkennen?
--repeat=10 für Mehrfachläufe, --order=random für Reihenfolgeabhängigkeiten. CI-Jobs mit identischem Commit und unterschiedlichen Ergebnissen sind ein klares Signal.
3ClockInterface – warum nötig?
Kapselt Zeitzugriff. FrozenClock im Test gibt immer denselben Zeitstempel zurück – komplett deterministisch, unabhängig von der Ausführungszeit.
4Datenbankzustand isolieren?
Transaktions-Rollback (schnell), Truncation (zuverlässig) oder SQLite in-memory (sehr schnell). Magento macht Rollback automatisch in Integrationstests.
5Bekannte Flaky Tests temporär ausschließen?
#[Group('flaky')] + --exclude-group flaky im CI. Temporäre Maßnahme – jeder ausgeschlossene Test braucht ein Issue und eine Priorität.
6Code mit random_int() testen?
RandomizerInterface einführen, im Test sequentielle Implementierung injizieren. PHP 8.2: Random\Randomizer mit austauschbarem Engine.
7Externe HTTP-APIs in Tests vermeiden?
Unit-Tests: HTTP-Client mocken. Integrationstests: VCR-Cassette mit php-vcr/php-vcr. Echte Netzwerkcalls gehören nicht in die reguläre Testsuite.
8Test-Reihenfolgeabhängigkeiten?
Tests, die globalen Zustand hinterlassen. Lösung: tearDown-Cleanup, Singletons durch DI ersetzen, --order=random zur Aufdeckung.
9Flaky Tests tolerieren?
Niemals dauerhaft. Kurzfristig ausschließen und als Bug tracken. Tolerierte Flaky Tests untergraben das Vertrauen und verbergen echte Regressionen.
10Flakiness bei Datei-Operationen verhindern?
setUp: temporäres Verzeichnis mit sys_get_temp_dir() . '/' . uniqid(). tearDown: rekursiv löschen. Nie feste /tmp-Pfade. Alternativ: vfsStream.