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.
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:
expect(page.getByText('Bereit')).toBeVisible()expect.pollauf 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.
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
waitForTimeoutdurchexpect.pollundexpect.toPassfür smarte Wartezeiten. - Idempotenz via
X-Idempotency-Keyeliminiert 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
Noch keine Kommentare.