Use-Case: Flaky Tests in Pipelines

Das Problem: Unsere automatisierten End-to-End-Tests (mittels Playwright 1.42) für das Analytics-Dashboard liefen hochgradig inkonsistent. In der GitHub-Actions-CI-Pipeline auf Ubuntu-22.04-Runnern scheiterten UI-Assertion-Checks in etwa 15 % der Läufe. Die betroffenen Tests waren immer dieselben: diejenigen, die mit WebGL-Canvas-Diagrammen interagierten – Charts, die ihre Daten via asynchronem XHR-Fetch aus einer internen REST-API zogen und dann via D3.js in ein Canvas-Element renderten.

Das Time-Delay Anti-Pattern und seine versteckten Kosten

Der anfängliche Quick-Fix der Entwickler waren hartcodierte await page.waitForTimeout(5000)-Aufrufe vor jedem kritischen Interaktionsschritt. Auf lokalen MacBook-M2-Maschinen funktionierten diese 5-Sekunden-Puffer zuverlässig. Auf den GitHub-Actions-Runnern mit geteilten CPU-Ressourcen konnte ein WebGL-Render-Vorgang bis zu 8 Sekunden dauern – das 5-Sekunden-Wait griff zu früh, der Test schlug fehl. Die Lösung war nicht, die Timeouts auf 10 Sekunden zu erhöhen: Die Core-Test-Suite umfasste 87 Tests, von denen 31 solche Waits enthielten. Bei 10 Sekunden pro Wait summierte sich die Suite-Laufzeit auf über 40 Minuten. Ein tödlicher Flaschenhals für agile Deployments.

CI Pipeline Dashboard

Root-Cause-Analyse: Die exakte Event-Sequenz

Der entscheidende Schritt war, die exakte Sequenz asynchroner Events zu verstehen, die nach page.goto('/dashboard/analytics') stattfindet. Mit Playwright's page.on('request') und page.on('response')-Hooks logten wir alle Netzwerkanfragen: 1) Initiales HTML lädt. 2) Bundle-JS und -CSS laden parallel. 3) Dashboard-Component mountet, triggert 4 simultane XHR-Requests zu /api/kpi/revenue, /api/kpi/users, /api/chart/timeline und /api/filter/options. 4) Nach Antwort der Data-Requests beginnt D3.js den SVG/Canvas-Render, der selbst 200–600 ms dauert. 5) Erst dann ist das .chart-canvas-Element interaktionsbereit. Ein simpler waitForSelector schlägt fehl, weil das Canvas-Element bereits im DOM existiert, bevor die Daten gerendert sind.

Die Network-Idle Strategie mit DOM Mutation Observer

Die Lösung ist eine zweistufige Synchronisationsstrategie. Stufe 1: page.waitForLoadState('networkidle') wartet dynamisch, bis für mindestens 500 ms keine einzige aktive Netzwerkanfrage mehr offen ist. Das dauert je nach Runner-Auslastung zwischen 800 ms und 4 Sekunden – immer korrekt, nie unnötig lang. Stufe 2: Ein zusätzlicher waitForFunction prüft im Browser-Kontext, ob das Canvas-Element eine definierte Mindest-Breite überschreitet (was beim erfolgreichen D3-Render passiert). Das kombiniert Network-Idle mit einer echten Renderbereitschafts-Prüfung.

// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
  timeout: 30000,
  retries: 1,
  workers: 4,
  reporter: [['html', { outputFolder: 'playwright-report' }]],
});

// analytics.spec.ts
import { test, expect } from '@playwright/test';

test('Analytics Dashboard renders after full data load', async ({ page }) => {
  // 1. Navigate with initial load wait
  await page.goto('/dashboard/analytics', { waitUntil: 'domcontentloaded' });

  // 2. Wait for ALL XHR requests to settle - dynamic, not time-based
  await page.waitForLoadState('networkidle', { timeout: 15000 });

  // 3. Verify D3 actually rendered by checking canvas pixel dimensions
  await page.waitForFunction(() => {
    const canvas = document.querySelector('.dynamic-chart-canvas');
    return canvas && canvas.width > 100; // Non-zero width = render complete
  }, { timeout: 5000 });

  // 4. Assert visibility and interaction readiness
  const canvas = page.locator('.dynamic-chart-canvas');
  await expect(canvas).toBeVisible();
  await expect(canvas).toHaveAttribute('data-render-status', 'complete');

  // 5. Safe interaction after verified DOM stability
  await page.click('#export-annual-report-btn');
  await expect(page.locator('.export-success-toast')).toBeVisible({ timeout: 3000 });
});

Ergebnis in Zahlen

Nach dem Refactoring aller 31 betroffenen Tests sank die Flaky-Test-Rate von 15 % auf unter 0.1 % (gemessen über 200 aufeinanderfolgende CI-Runs). Die durchschnittliche Suite-Laufzeit reduzierte sich von bis zu 22 Minuten (auf langsamen Runnern mit 5s-Waits) auf stabile 9.2 Minuten. Der einzige verbleibende Retry-Fall ist ein echter intermittierender Docker-Network-Fehler des internen Test-Stubs – kein falsches Positiv.