Flaky Tests vermeiden: Ursachen und robuste Lösungen
PASS
expect()
Testing · Flaky Tests · Cypress · Playwright
Flaky Tests vermeiden: Ursachen und robuste Lösungen
Warum E2E-Tests inkonsistent fehlschlagen, und wie man das behebt

Flaky Tests sind End-to-End-Tests, die ohne Codeänderung mal grün und mal rot laufen, und sie untergraben das Vertrauen in die gesamte Testsuite. Die Ursachen liegen fast immer in Timing-Problemen, geteiltem Testzustand, Netzwerkabhängigkeiten oder fragilen Selektoren. Dieser Artikel zeigt, wie man diese Ursachen systematisch findet, robuste Cypress- und Playwright-Patterns einsetzt und flaky Tests kontrolliert quarantänisiert, statt sie stillschweigend zu ignorieren.

13 Min. Lesezeit Flaky Tests · Stabilität · CI/CD Cypress · Playwright · Selektoren

1. Was macht einen Test "flaky"

Ein flaky Test ist ein Test, der ohne jede Änderung am Anwendungscode und ohne Änderung am Testcode mal erfolgreich durchläuft und mal fehlschlägt. Das unterscheidet ihn fundamental von einem deterministisch fehlschlagenden Test, der einen echten Bug aufdeckt, und von einem Test, der ausschließlich in einer bestimmten Umgebung bricht. Flakiness ist nichtdeterministisch: Derselbe Commit, derselbe Testlauf-Befehl, dieselbe Testdatenbank, und trotzdem wechselt das Ergebnis zwischen grün und rot. Diese Eigenschaft macht flaky Tests besonders schwer zu debuggen, weil ein einzelner fehlgeschlagener Lauf sich nicht zuverlässig reproduzieren lässt.

Auf System- und E2E-Ebene, wo Cypress und Playwright den kompletten Stack aus Browser, Netzwerk, Backend und Datenbank gleichzeitig durchlaufen, gibt es strukturell mehr bewegliche Teile als bei einem isolierten PHPUnit-Test einer einzelnen Klasse. Genau diese zusätzlichen Schichten, asynchrones Rendering, Netzwerklatenz, gemeinsam genutzter Datenbankzustand, sind die eigentliche Quelle der meisten Flakiness-Fälle. Der Schaden ist real: Ein Team, das gewohnheitsmäßig rote Pipelines neu startet, weil "der Test eh manchmal flackert", verliert irgendwann die Fähigkeit, einen echten Regressionsfehler von einem bekannten Rauschen zu unterscheiden.

2. Timing- und Race-Condition-Probleme

Race Conditions entstehen, wenn ein Test schneller vorankommt als die Anwendung selbst. Ein Klick auf einen Button löst einen asynchronen API-Aufruf aus, doch der Test prüft die Erfolgsmeldung, bevor die Antwort überhaupt zurückgekommen ist. Genauso häufig ist das Gegenteil: Ein React- oder Vue-Rerender braucht einen zusätzlichen Tick, ein Modal wird erst nach einer CSS-Transition wirklich interaktionsfähig, oder ein Alpine.js-x-init-Hook setzt den initialen Zustand erst nach dem ersten Paint. Für den Test sieht das Element bereits im DOM aus, ist aber funktional noch nicht bereit.

Der reflexartige Fix ist ein hartcodiertes cy.wait(2000), das Symptom kurzfristig kaschiert, aber die eigentliche Ursache nicht behebt: Bei langsameren CI-Runnern reicht die Wartezeit plötzlich nicht mehr, bei schnelleren verschwendet sie unnötig Laufzeit. Cypress-Befehle wie cy.get() und Assertions wie should() sind bereits eingebaut retry-fähig und pollen die Anwendung bis zu einem konfigurierbaren Timeout, statt einmalig zu prüfen. Wer stattdessen auf konkrete Zustandsänderungen wartet, etwa auf ein Netzwerk-Alias mit cy.wait('@getOrders') oder auf eine sichtbare Erfolgsmeldung mit should('be.visible'), koppelt den Test an das tatsächliche Anwendungsverhalten statt an eine geschätzte Zeitspanne.


// VORHER: fragile, hartcodierte Wartezeit setzt fixes Timing voraus
describe('checkout', () => {
  it('shows order confirmation', () => {
    cy.get('[data-testid="place-order"]').click();
    cy.wait(2000); // Raetselraten: zu kurz auf langsamem CI, unnoetig lang auf schnellem CI
    cy.get('[data-testid="confirmation-message"]').should('contain', 'Thank you');
  });
});

// NACHHER: auf die tatsaechliche Netzwerkantwort warten, Rest uebernimmt die Retry-Faehigkeit
describe('checkout', () => {
  it('shows order confirmation', () => {
    cy.intercept('POST', '/rest/V1/carts/*/order').as('placeOrder');

    cy.get('[data-testid="place-order"]').click();
    cy.wait('@placeOrder').its('response.statusCode').should('eq', 200);

    // Cypress wiederholt diese Assertion automatisch bis Erfolg oder Timeout
    cy.get('[data-testid="confirmation-message"]').should('contain', 'Thank you');
  });
});

Playwright verfolgt denselben Grundgedanken noch konsequenter: Jede Locator-Aktion wie click() oder fill() führt vor der Ausführung automatisch Actionability-Checks durch, prüft also, ob das Element sichtbar, aktiviert und nicht durch ein anderes Element verdeckt ist. Dadurch entfällt für die meisten Timing-Probleme jede manuelle Wartelogik von vornherein, und Tests, die in Cypress noch explizite Waits brauchen, laufen in Playwright oft ohne jede Anpassung stabil.

3. Geteilter Testzustand als Fehlerquelle

Ein klassisches Flakiness-Muster in E2E-Suiten: Test A legt über die UI ein Produkt in den Warenkorb, Test B geht implizit davon aus, dass der Warenkorb aus Test A noch existiert, und beide teilen sich denselben Test-Account. Läuft die Suite sequenziell und in fester Reihenfolge, funktioniert das zufällig. Sobald Tests parallel auf mehreren Workern laufen oder die Ausführungsreihenfolge sich durch Sharding ändert, kollidieren zwei Tests auf demselben Datensatz, und der Test, der zufällig zweiter ist, schlägt fehl, obwohl der Code korrekt ist.

Der robuste Ansatz trennt Testdaten konsequent pro Testfall: Jeder Test erzeugt seinen eigenen Kunden, seine eigene Bestellung oder seinen eigenen Warenkorb, idealerweise über einen direkten API-Aufruf statt über die UI, weil das schneller ist und keine zusätzlichen Timing-Abhängigkeiten einführt. Eindeutige Bezeichner, etwa per uuid oder Zeitstempel im E-Mail-Präfix, verhindern Kollisionen zwischen parallelen Workern. Zusätzlich sollte jede Testumgebung entweder pro Testlauf zurückgesetzt oder die Datenbankänderungen am Ende jedes Tests per Transaktion zurückgerollt werden, damit kein Test von Nebenwirkungen eines vorherigen Tests abhängt.


// VORHER: Test verlaesst sich auf UI-Zustand, den ein vorheriger Test hinterlassen hat
test('customer sees their cart total', async ({ page }) => {
  await page.goto('/checkout/cart'); // setzt voraus, dass zuvor ein Artikel hinzugefuegt wurde
  await expect(page.getByTestId('cart-total')).toHaveText('$49.90');
});

// NACHHER: isolierten Warenkorb vor dem Test per API anlegen
import { test as base, expect } from '@playwright/test';

const test = base.extend({
  seededCart: async ({ request }, use) => {
    const customerEmail = `qa-${Date.now()}@example.com`;
    const response = await request.post('/rest/V1/guest-carts', {
      data: { email: customerEmail, items: [{ sku: 'MS-1234', qty: 1 }] }
    });
    const { cartId } = await response.json();
    await use({ cartId, customerEmail });
  }
});

test('customer sees their cart total', async ({ page, seededCart }) => {
  await page.goto(`/checkout/cart?cart_id=${seededCart.cartId}`);
  await expect(page.getByTestId('cart-total')).toHaveText('$49.90');
});

4. Netzwerkabhängigkeiten und externe Services

Sobald ein E2E-Test eine echte Drittanbieter-API anspricht, etwa ein Payment-Sandbox, einen Versanddienstleister oder einen E-Mail-Provider, importiert der Test automatisch deren Verfügbarkeit und Latenz in die eigene Suite. Ein kurzzeitiger Ausfall der Sandbox, ein Rate-Limit, oder auch nur ein langsamer Antwortzeitpunkt an einem stark ausgelasteten Tag führt zu einem roten Test, obwohl die eigene Anwendung fehlerfrei funktioniert. Besonders tückisch sind Consent-Banner oder A/B-Test-Frameworks von Drittanbietern, die je nach IP oder Zufallszahl unterschiedliches Markup ausliefern und Selektoren dadurch inkonsistent treffen.

Die robuste Lösung ist, externe Abhängigkeiten in den meisten Tests gezielt zu stubben, statt sie live anzusprechen. Mit cy.intercept() in Cypress oder page.route() in Playwright lässt sich jede HTTP-Anfrage abfangen und mit einer festen, deterministischen Antwort beantworten, wodurch Latenz und Verfügbarkeit der echten Services keine Rolle mehr spielen. Wichtig ist, das nicht als Freifahrtschein für vollständige Isolation zu verstehen: Eine kleine Zahl echter Smoke-Tests gegen die tatsächliche Integration bleibt notwendig, um zu erkennen, wenn sich die Antwortstruktur der Drittanbieter-API tatsächlich ändert und die gestubbten Fixtures veralten.


// Versandkosten-API stubben, damit der Test nicht von einer Live-Sandbox abhaengt
cy.intercept('GET', '/rest/V1/shipping/rates*', {
  statusCode: 200,
  body: [
    { carrier: 'standard', label: 'Standard Shipping', price: 4.99 },
    { carrier: 'express', label: 'Express Shipping', price: 12.99 }
  ]
}).as('shippingRates');

cy.visit('/checkout/shipping');
cy.wait('@shippingRates');
cy.get('[data-testid="shipping-option-express"]').click();
cy.get('[data-testid="shipping-total"]').should('contain', '12.99');

5. Animationen und zeitabhängiges UI

CSS-Transitions und JavaScript-Animationen sind eine unterschätzte Flakiness-Quelle, weil sie einen Zustand erzeugen, der weder eindeutig "fertig" noch eindeutig "nicht fertig" ist. Ein Modal, das über 300 Millisekunden einblendet, existiert für den Test bereits im DOM, ist aber während der Transition teilweise durchsichtig oder noch nicht klickbar, weil pointer-events erst nach Abschluss der Animation aktiviert werden. Toast-Benachrichtigungen, die sich nach drei Sekunden automatisch schließen, erzeugen ein enges Zeitfenster, in dem eine Assertion zufällig zu spät greift, besonders wenn der CI-Runner unter Last langsamer reagiert als die lokale Entwicklungsumgebung.

Der zuverlässigste Fix ist, Animationen in der Testumgebung grundsätzlich zu deaktivieren oder drastisch zu verkürzen, etwa über eine CSS-Regel, die bei einem Test-Query-Parameter oder einer Umgebungsvariable alle transition- und animation-duration-Werte auf 0ms setzt. Wo das nicht möglich ist, sollte die Assertion nicht auf einen visuellen Zwischenzustand zielen, sondern auf ein semantisches Endsignal wie ein aria-expanded-Attribut, eine hinzugefügte CSS-Klasse oder ein data-state-Attribut, das die Anwendung erst nach Abschluss der Transition setzt. Playwright wartet vor Screenshot-Vergleichen automatisch auf das Ende laufender CSS-Transitions, was diese Klasse von Fehlern bei visuellen Tests zusätzlich entschärft.

6. Robuste Selektorstrategien

Selektoren, die an Implementierungsdetails hängen, sind der zweithäufigste Flakiness-Auslöser nach Timing-Problemen. Ein Selektor wie .flex.items-center.gap-2 bricht bei jedem Tailwind-Refactoring, weil Utility-Klassen Styling ausdrücken, keine stabile Identität. nth-child(3)-Selektoren brechen, sobald sich die Reihenfolge der Elemente im Markup ändert, etwa durch ein neues Feature-Flag oder eine bedingte Anzeige. Textbasierte Selektoren brechen bei jeder Copy-Änderung oder Internationalisierung, weil der sichtbare Text und die Testlogik direkt gekoppelt sind, obwohl beide unabhängig voneinander geändert werden sollten.

data-testid-Attribute lösen dieses Problem, weil sie einen expliziten Vertrag zwischen Anwendungscode und Testcode bilden, der von CSS-Refactorings und Copy-Änderungen komplett unabhängig ist. Playwright geht mit rollenbasierten Locators wie getByRole(), getByLabel() und getByText() noch einen Schritt weiter: Diese Locators spiegeln wider, wie tatsächliche Nutzer und Screenreader ein Element finden, und belohnen zusätzlich zugängliches Markup, weil ein Button ohne korrekte Rolle plötzlich schwerer zu selektieren ist. In der Praxis bewährt sich eine Hierarchie: rollenbasierte Locators zuerst, data-testid als Fallback für Elemente ohne aussagekräftige semantische Rolle, und CSS-Selektoren nur als letzte Option.


// Rollenbasierte Locators spiegeln wider, wie echte Nutzer Elemente finden, und warten automatisch
await page.getByRole('button', { name: 'Add to cart' }).click();

// Web-First-Assertions wiederholen sich automatisch bis Bedingung erfuellt oder Timeout erreicht
await expect(page.getByTestId('cart-count')).toHaveText('1');

// Fallback: data-testid fuer Elemente ohne aussagekraeftige semantische Rolle
await expect(page.getByTestId('mini-cart-total')).toHaveText('$49.90');

// Vermeiden: fragile Selektoren, die an Styling oder DOM-Position haengen
// await page.locator('.flex.items-center.gap-2 > div:nth-child(3)').click();

7. Retry-Mechanismen versus Ursachenbehebung

Retry-Mechanismen wie Cypress' retries: { runMode: 2, openMode: 0 } oder Playwrights retries-Konfiguration wiederholen einen fehlgeschlagenen Test bis zu einer definierten Anzahl, bevor die Suite ihn endgültig als rot markiert. Das ist ein legitimes Sicherheitsnetz gegen echtes Infrastruktur-Rauschen wie einen kurzzeitigen DNS-Hänger im CI-Netzwerk, wird aber zum Problem, wenn Teams Retries als primäre Strategie gegen Flakiness einsetzen. Ein Test, der im zweiten Versuch grün wird, wird stillschweigend als Erfolg gewertet, obwohl die zugrunde liegende Race Condition oder das fragile Selektor-Problem unverändert im Code bleibt.

Retries sollten als Messinstrument verstanden werden, nicht als Reparatur. Playwrights HTML-Report markiert wiederholte Tests explizit, und dieselbe Information gehört in ein Dashboard, das die Retry-Quote pro Test über die Zeit sichtbar macht. Ein Test, der gelegentlich einmal einen Retry braucht, weil ein CI-Runner kurz überlastet war, ist unauffällig. Ein Test, der in jedem dritten Lauf einen Retry braucht, hat ein strukturelles Problem, das ein eigenes Ticket verdient, keine weitere Erhöhung der Retry-Zahl. Die Faustregel: Retries dürfen die Symptome einer seltenen, echten Infrastruktur-Störung abfedern, niemals aber eine bekannte, reproduzierbare Ursache dauerhaft verstecken.

8. Flaky Tests quarantänisieren statt ignorieren

Ein bekanntlich flaky Test einfach mit .skip zu deaktivieren, ist der Anfang vom Ende der Testabdeckung: Der Test verschwindet aus dem Bewusstsein des Teams, wird nie wieder angefasst, und die Funktionalität, die er eigentlich absichern sollte, bleibt langfristig ungetestet. Quarantäne ist die bewusste Alternative dazu: Ein als flaky markierter Test läuft weiterhin, blockiert aber nicht mehr die Haupt-Pipeline, und sein Ergebnis wird getrennt sichtbar gemacht statt den gesamten Build rot zu färben. Das hält die Hauptpipeline vertrauenswürdig, ohne die Testabdeckung tatsächlich zu verlieren.

Damit Quarantäne nicht zur dauerhaften Ablage wird, braucht sie einen Prozess: einen verantwortlichen Owner, ein Ticket mit Kontext zur vermuteten Ursache, und idealerweise ein Ablaufdatum, nach dem der Test entweder repariert oder endgültig entfernt werden muss. Ein Dashboard, das die Anzahl quarantänisierter Tests über die Zeit zeigt, macht Flakiness zu einer sichtbaren Hygiene-Metrik, die aktiv gegen null tendieren sollte, statt in einer vergessenen Testdatei zu verschwinden. Tag-basierte Markierung, etwa mit einem @flaky-Grep-Tag in Cypress oder einem eigenen Projekt in der Playwright-Konfiguration, trennt technisch sauber zwischen blockierenden und nicht-blockierenden Testläufen, ohne den Test aus der Suite zu entfernen.


# .github/workflows/e2e.yml
name: E2E Tests
on: [pull_request]

jobs:
  e2e-stable:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      # Haupt-Suite: schliesst quarantaenisierte Specs aus, blockiert den Merge bei Fehlern
      - name: Run stable E2E suite
        run: npx playwright test --grep-invert @flaky --retries=1

  e2e-quarantine:
    runs-on: ubuntu-latest
    continue-on-error: true # sichtbar, blockiert die Pipeline aber nie
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      # Quarantaenisierte Specs: laufen und werden reportet, aber nicht blockierend
      - name: Run quarantined E2E suite
        run: npx playwright test --grep @flaky --retries=2
      - name: Upload quarantine report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: quarantine-report
          path: playwright-report/

9. Flakiness-Ursachen im direkten Vergleich

Jede der in diesem Artikel behandelten Flakiness-Ursachen hat ein charakteristisches fragiles Muster und eine ebenso charakteristische robuste Alternative. Die folgende Übersicht fasst die wichtigsten Gegenüberstellungen zusammen, sortiert nach Häufigkeit in typischen Magento- und Hyvä-Frontends.

Ursache Fragiles Muster Robuste Lösung
Timing / Race Conditions cy.wait(2000) als fixe Wartezeit cy.wait('@alias') / retry-fähige Assertions
Geteilter Testzustand Gemeinsamer Test-Account für alle Specs Isolierte Daten pro Test via API-Fixture
Netzwerkabhängigkeit Live-Aufruf einer Drittanbieter-Sandbox cy.intercept() / page.route() Stubbing
Animationen Klick während laufender CSS-Transition Transition deaktivieren, auf Endzustand warten
Selektoren .flex.items-center.gap-2 / nth-child(3) getByRole() / data-testid

In der Praxis überschneiden sich diese Ursachen häufig: Ein Test mit fragilem Selektor scheitert oft gerade deshalb, weil er während einer Animation nach dem falschen Element sucht, oder ein Timing-Problem wird durch geteilten Zustand zusätzlich verschärft, wenn zwei parallele Worker denselben Datensatz manipulieren. Wer die robusten Muster aus der Tabelle konsequent kombiniert, reduziert die Flakiness-Quote typischerweise um eine Größenordnung, ohne die eigentliche Testlogik zu verändern.

Mironsoft

Test-Stabilität, Cypress/Playwright-Suiten und CI/CD für Magento- und Hyvä-Shops

Flaky Tests dauerhaft aus eurer Suite verbannen?

Wir analysieren bestehende Cypress- und Playwright-Suiten, identifizieren die konkreten Flakiness-Ursachen und implementieren robuste Selektorstrategien, Testisolation und CI-Stabilisierung, damit eure Pipeline wieder ein verlässliches Signal statt Rauschen liefert.

Flakiness-Audit

Systematische Analyse bestehender Suites, Retry-Statistiken und Ursachenanalyse pro Test

Cypress/Playwright Refactoring

Robuste Selektoren, Testisolation und Netzwerk-Stubbing statt fragiler Ad-hoc-Tests

CI-Stabilisierung

Quarantäne-Workflows, Retry-Strategien und Dashboards für nachhaltig grüne Pipelines

10. Zusammenfassung

Flaky Tests vermeiden bedeutet nicht, Zufall aus einer inhärent nichtdeterministischen Umgebung, Browser, Netzwerk, Backend-Zustand, zu eliminieren, sondern die Kopplung zwischen Test und diesen unsicheren Faktoren zu reduzieren. Timing-Probleme lösen sich durch retry-fähige Assertions statt hartcodierter Wartezeiten. Geteilter Zustand löst sich durch API-basierte Testisolation mit eindeutigen Daten pro Testlauf. Netzwerkabhängigkeiten löst man durch gezieltes Stubbing der Drittanbieter-Antworten, kombiniert mit einer kleinen Zahl echter Smoke-Tests. Animationen verlieren ihren Flakiness-Charakter, sobald sie in der Testumgebung deaktiviert oder über semantische Endzustände statt visuelle Zwischenzustände abgefragt werden.

Retries und Quarantäne sind keine Widersprüche zu diesem Ansatz, sondern ergänzende Werkzeuge: Retries fangen seltenes, echtes Infrastruktur-Rauschen ab und liefern gleichzeitig Messdaten darüber, welche Tests strukturelle Probleme haben. Quarantäne hält die Hauptpipeline vertrauenswürdig, ohne bekannte Problemfälle stillschweigend aus der Suite verschwinden zu lassen. Der Unterschied zwischen einer Testsuite, der niemand mehr traut, und einer, die verlässlich echte Regressionen meldet, liegt selten in einem einzelnen großen Rewrite, sondern in der konsequenten Anwendung dieser Muster über alle Specs hinweg.

Flaky Tests vermeiden, Ursachen und robuste Lösungen: Das Wichtigste auf einen Blick

Ursachen erkennen

Timing, geteilter Zustand, Netzwerk und Animationen sind die häufigsten Quellen für flaky E2E-Tests.

Robuste Selektoren

data-testid und rollenbasierte Locators statt CSS-Klassen oder Positionsselektoren verwenden.

Retry als Signal, nicht als Fix

Retries dokumentieren Flakiness über die Zeit, sie beheben die zugrunde liegende Ursache nicht.

Quarantäne statt Löschen

Bekannte flaky Tests sichtbar isolieren, mit Owner und Ablaufdatum, statt sie einfach zu skippen.

11. FAQ: Flaky Tests vermeiden

1Was ist ein flaky Test genau?
Ein Test, der ohne Codeänderung mal erfolgreich läuft und mal fehlschlägt. Nichtdeterministisch und deshalb schwerer zu reproduzieren als ein echter Bug.
2Warum ist cy.wait(2000) ein Antipattern?
Eine feste Wartezeit rät ein Timing, statt es zu kennen. Reicht auf langsamem CI nicht aus, verschwendet auf schnellem CI Laufzeit. Retry-fähige Assertions sind robuster.
3Wie erkenne ich flaky vs. echten Bug?
Denselben Test mehrfach mit identischem Code und identischen Daten laufen lassen. Wechselndes Ergebnis bedeutet Flakiness, konstant rot bedeutet reproduzierbaren Bug.
4Sind Retries in der CI-Pipeline akzeptabel?
Als Sicherheitsnetz gegen seltenes Rauschen ja, als primäre Strategie nein. Regelmäßig nötige Retries zeigen ein strukturelles Problem, das behoben werden muss.
5Wie isoliere ich parallel laufende Tests?
Eigene Daten pro Test per API mit eindeutigen Bezeichnern anlegen. Datenbank pro Testlauf zurücksetzen oder Änderungen per Transaktion zurückrollen.
6Wann data-testid statt CSS-Selektoren?
Immer, wenn kein aussagekräftiger rollenbasierter Locator existiert. Entkoppelt den Test von CSS-Refactorings und ist stabiler als Klassen- oder Positionsselektoren.
7Wie mit Animationen in E2E-Tests umgehen?
Animationen deaktivieren oder auf 0ms verkürzen. Assertions auf semantisches Endsignal statt visuellen Zwischenzustand richten.
8Quarantäne vs. geskippter Test?
Geskippt läuft gar nicht mehr und verschwindet. Quarantänisiert läuft weiter, wird reportet, blockiert aber nicht, mit Owner und Ablaufdatum.
9Wie externe APIs stubben, ohne Aussagekraft zu verlieren?
Mehrzahl der Tests deterministisch stubben, kleine Zahl echter Smoke-Tests gegen die echte Integration behalten, um API-Änderungen rechtzeitig zu erkennen.
10Wie messe ich Flakiness über die Zeit?
Über ein Dashboard mit Retry-Häufigkeit pro Test und Anzahl quarantänisierter Tests. Playwrights HTML-Report markiert wiederholte Tests bereits automatisch.