Stabilizacja testów E2E: walka z flakami bez waitForTimeout
W środowiskach CI/CD testy E2E na Playwright często flakują z powodu zwiększonego obciążenia, opóźnień sieci i konkurencji o zasoby. Lokalnie mocny CPU i niskie opóźnienie maskują race conditions: frontend wyrenderował UI, ale backend jeszcze nie zatwierdził transakcji w bazie danych. W CI warunki są gorsze — testy widzą niespójny stan i padają losowo.
Klucz do stabilności: przejście od statycznych opóźnień do dynamicznego oczekiwania, idempotencja żądań i niezawodna izolacja zależności. Omówimy techniki, które redukują flaki w dużych zestawach testów.
Dynamiczne oczekiwanie zamiast sztywnych timeoutów
waitForTimeout(5000) — anty-wzorzec. W 90% przypadków flak występuje z powodu niezgodności timingów: backend odpowiedział w 5001 ms zamiast oczekiwanych 5000. Rozwiązanie — expect.poll, który sprawdza stan aż do sukcesu lub timeoutu.
await expect.poll(async () => {
const order = await api.getOrder(id);
return order.status;
}, {
message: 'Oczekujemy statusu PAID',
timeout: 30000,
}).toBe('PAID');
Playwright automatycznie dobiera interwały odpytywania. Dla wolnych scenariuszy ustaw test.setTimeout(60000), ale nie zmieniaj intervals ręcznie.
Pułapka networkidle i alternatywy
waitUntil: 'networkidle' czeka 500 ms bez aktywności sieciowej. Problem: analityka, widżety czatu lub pingsy zakłócają ciszę, test wisi aż do globalnego timeoutu.
Zasada: czekaj na konkretne elementy lub odpowiedzi API:
expect(page.getByText('Gotowe')).toBeVisible()expect.pollna status backendu
Dla powtarzania bloków akcji użyj expect.toPass:
await expect(async () => {
await page.getByRole('button', { name: 'Odśwież' }).click();
await expect(page.getByText('Status: Gotowe')).toBeVisible();
}).toPass({
timeout: 15000
});
Idempotencja dla ochrony przed awariami sieciowymi
W systemach rozproszonych retry przy awarii sieciowej tworzy duplikaty: pierwszy POST przeszedł, odpowiedź się zgubiła, powtórne żądanie duplikuje encję. Rezultat — błędy 400 lub niespójny stan.
Rozwiązanie: generuj Idempotency-Key na podstawie metody, URL i payloadu.
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);
}
// W 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 }
});
}
Backend po kluczu zwraca wynik z cache. Flaki od retry znikają.
Mocki: poziomy izolacji od prostych do kontraktów
Native mocks z page.route
Dla izolacji UI od backendu:
await page.route('**/api/orders', route => {
route.fulfill({
status: 500,
body: JSON.stringify({ error: 'Wewnętrzny błąd serwera' })
});
});
await page.goto('/orders');
await expect(page.getByText('Coś poszło nie tak')).toBeVisible();
Ograniczenie: nie łapie żądań server-side przez fixture request.
Mocki infrastrukturalne (WireMock)
Backend zależy od zewnętrznych serwisów (płatności, SMS)? Podnieś WireMock:
# docker-compose.yml
services:
wiremock:
image: wiremock/wiremock:3.3.1
ports:
- "8080:8080"
volumes:
- ./wiremock/mappings:/home/wiremock/mappings
Przykład mapowania (payment.json):
{
"request": {
"method": "POST",
"url": "/v1/payments"
},
"response": {
"status": 200,
"jsonBody": {
"payment_id": "pay_test_123",
"status": "succeeded"
}
}
}
Testy nie zależą od zewnętrznego uptime'u.
Contract Testing przeciw Lying Mocks
Mocki kłamią: frontend oczekuje order_id, backend zwrócił orderId — testy zielone, produkcja pada.
Consumer-Driven Contracts (Pact) utrwalają oczekiwania w kontrakcie JSON:
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
const provider = new PactV3({
consumer: 'frontend-tests',
provider: 'order-service',
dir: './pacts',
});
it('zwraca zamówienie po id', async () => {
await provider
.given('zamówienie ord_123 istnieje')
.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 weryfikuje kontrakt w swoim CI. Zmiany API są wychwytywane przed deploymentem.
Higiena danych testowych
Testy zaśmiecają bazę danych — wydajność spada, testy flakują z powodu unikalnych constraints.
1. TTL z tłem oczyszczającym
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. Kolejka czyszczenia
Zbieraj ID w kolejkę przy tworzeniu, czyść w globalnym 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. Partycjonowanie tabel
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');
Drop partycji — operacja O(1).
Co ważne:
- Zastąp
waitForTimeoutnaexpect.polliexpect.toPassdla dynamicznego oczekiwania. - Idempotencja przez
X-Idempotency-Keyeliminuje duplikaty od retry. - Ewolucja mocków: page.route → WireMock → Pact dla pełnej izolacji.
- TTL + cron/kolejki/partycje dla higieny danych.
- Stabilność — problem systemowy: naprawiaj backend i infra wraz z testami.
— Editorial Team
Brak komentarzy.