Zurück zur Startseite

Playwright-Tests ohne Flakes stabilisieren

Der Artikel analysiert die Ursachen von Flakes in Playwright E2E-Tests und bietet Lösungen: dynamisches Polling expect.poll, Idempotenz, mehrstufige Mocks bis Pact, Strategien zur Bereinigung von Testdaten. Praktische Code-Beispiele für Stabilität in CI.

Flakes in Playwright bekämpfen: poll, Mocks, Verträge
Advertisement 728x90

Playwright E2E-Tests stabilisieren: Flake-freie Strategien ohne feste Wartezeiten

In CI/CD-Pipelines flaken Playwright E2E-Tests häufig durch hohe Last, Netzwerkverzögerungen und Ressourcenkonflikte. Lokal kaschieren starke CPUs und geringe Latenz Race Conditions: Das Frontend rendert die UI, aber das Backend hat die Transaktion noch nicht in die Datenbank committed. In CI sind die Bedingungen härter – Tests erfassen inkonsistente Zustände und scheitern zufällig.

Der Schlüssel zur Zuverlässigkeit: Wechseln Sie von statischen Verzögerungen zu dynamischem Polling, idempotenten Requests und robuster Abhängigkeitsisolierung. Tauchen wir in Techniken ein, die Flakiness im großen Maßstab eliminieren.

Dynamisches Polling statt starrer Timeouts

waitForTimeout(5000) ist ein Anti-Pattern. In 90 % der Fälle entstehen Flakes durch Timing-Probleme: Das Backend antwortet nach 5001 ms statt den erwarteten 5000. Die Lösung? expect.poll, das den Zustand prüft, bis Erfolg oder Timeout eintritt.

Google AdInline article slot
await expect.poll(async () => {
  const order = await api.getOrder(id);
  return order.status;
}, {
  message: 'Warten auf PAID-Status',
  timeout: 30000,
}).toBe('PAID');

Playwright passt Polling-Intervalle automatisch an. Bei langsamen Szenarien erhöhen Sie test.setTimeout(60000), passen Sie Intervalle aber nicht manuell an.

Die Falle von networkidle und bessere Alternativen

waitUntil: 'networkidle' wartet 500 ms ohne Netzwerkaktivität. Problem: Analytics, Chat-Widgets oder Pings unterbrechen die Stille, der Test hängt bis zum globalen Timeout.

Regel: Warten Sie auf spezifische Elemente oder API-Antworten:

Google AdInline article slot
  • expect(page.getByText('Bereit')).toBeVisible()
  • expect.poll auf Backend-Status

Für wiederholte Aktionen nutzen Sie expect.toPass:

await expect(async () => {
  await page.getByRole('button', { name: 'Aktualisieren' }).click();
  await expect(page.getByText('Status: Bereit')).toBeVisible();
}).toPass({
  timeout: 15000
});

Idempotenz schützt vor Netzwerkproblemen

In verteilten Systemen erzeugen Retries bei Netzwerkaussetzern Duplikate: Der erste POST gelingt, die Antwort geht verloren, der Retry dupliziert die Entität. Ergebnis? 400-Fehler oder inkonsistente Zustände.

Lösung: Generieren Sie einen Idempotency-Key aus Methode, URL und Payload.

Google AdInline article slot
import { createHash } from 'crypto';

export function generateIdempotencyKey(method: string, url: string, data: any): string {
  const payload = `${method}:${url}:${JSON.stringify(data)}`;
  return createHash('sha256').update(payload).digest('hex').slice(0, 16);
}

// In BaseApiClient
protected async post(url: string, data?: any) {
  const key = generateIdempotencyKey('POST', url, data);
  return await this.request.post(url, {
    data,
    headers: { 'X-Idempotency-Key': key }
  });
}

Das Backend liefert gecachte Ergebnisse pro Key. Retry-bedingte Flakes verschwinden.

Mocks: Isolationsstufen von einfach bis contract-driven

Native Mocks mit page.route

Zur Isolation der UI vom Backend:

await page.route('**/api/orders', route => {
  route.fulfill({
    status: 500,
    body: JSON.stringify({ error: 'Interner Serverfehler' })
  });
});

await page.goto('/orders');
await expect(page.getByText('Etwas ist schiefgelaufen')).toBeVisible();

Einschränkung: Serverseitige Requests via request-Fixture werden verpasst.

Infrastruktur-Mocks (WireMock)

Backend abhängig von externen Diensten (Zahlungen, SMS)? Starten Sie WireMock:

# docker-compose.yml
services:
  wiremock:
    image: wiremock/wiremock:3.3.1
    ports:
      - "8080:8080"
    volumes:
      - ./wiremock/mappings:/home/wiremock/mappings

Mapping-Beispiel (payment.json):

{
  "request": {
    "method": "POST",
    "url": "/v1/payments"
  },
  "response": {
    "status": 200,
    "jsonBody": {
      "payment_id": "pay_test_123",
      "status": "succeeded"
    }
  }
}

Tests bleiben unabhängig von externer Verfügbarkeit.

Contract Testing vs. täuschende Mocks

Mocks lügen: Frontend erwartet order_id, Backend sendet orderId – Tests laufen grün, Prod crasht.

Consumer-Driven Contracts (Pact) fixieren Erwartungen in JSON-Verträgen:

import { PactV3, MatchersV3 } from '@pact-foundation/pact';

const provider = new PactV3({
  consumer: 'frontend-tests',
  provider: 'order-service',
  dir: './pacts',
});

it('returns order by id', async () => {
  await provider
    .given('order ord_123 exists')
    .uponReceiving('GET /orders/ord_123')
    .withRequest({ method: 'GET', path: '/orders/ord_123' })
    .willRespondWith({
      status: 200,
      body: {
        order_id: MatchersV3.string('ord_123'),
        status: MatchersV3.string('PAID'),
      },
    })
    .executeTest(async (mockServer) => {
      const order = await fetchOrder(mockServer.url, 'ord_123');
      expect(order.status).toBe('PAID');
    });
});

Backend verifiziert den Vertrag in seinem CI. API-Änderungen werden vor dem Deploy erkannt.

Testdaten-Hygiene

Tests füllen die DB – Performance leidet, Unique-Constraints verursachen Flakes.

1. TTL mit Hintergrund-Cleanup

await api.createUser({
  email: `test_${Date.now()}@example.com`, 
  expires_at: new Date(Date.now() + 24*60*60*1000).toISOString()
});

PostgreSQL pg_cron:

SELECT cron.schedule('cleanup-test-data', '0 * * * *', $$
  DELETE FROM users WHERE expires_at < NOW() AND is_test = true;
$$);

2. Cleanup-Queue

Sammeln Sie IDs in einer Queue beim Erstellen, löschen Sie in globalem Teardown:

protected async post(url: string, data?: any) {
  const response = await this.request.post(url, { data });
  const body = await response.json();
  
  if (body.id) {
    cleanupQueue.push({ url, id: body.id });
  }
  return response;
}

3. Tabellen-Partitionierung

CREATE TABLE orders_test (
  id UUID, created_at TIMESTAMP
) PARTITION BY RANGE (created_at);

CREATE TABLE orders_test_2024_06 
  PARTITION OF orders_test
  FOR VALUES FROM ('2024-06-01') TO ('2024-07-01');

Partitions löschen ist O(1).

Wichtige Erkenntnisse:

  • Ersetzen Sie waitForTimeout durch expect.poll und expect.toPass für smarte Wartezeiten.
  • Idempotenz via X-Idempotency-Key eliminiert Retry-Duplikate.
  • Mock-Entwicklung: page.route → WireMock → Pact für echte Isolation.
  • TTL + Cron/Queues/Partitions für saubere Daten.
  • Stabilität ist systemisch: Backend und Infra neben Tests optimieren.

— Editorial Team

Advertisement 728x90

Weiterlesen