Powrót do strony głównej

Stabilizacja testów Playwright bez flaków

Artykuł analizuje przyczyny flaków w testach E2E Playwright i proponuje rozwiązania: dynamiczne oczekiwanie expect.poll, idempotentność, mocki wielopoziomowe do Pact, strategie czyszczenia danych testowych. Praktyczne przykłady kodu dla stabilności w CI.

Walka z flakami w Playwright: poll, mocki, kontrakty
Advertisement 728x90

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.

Google AdInline article slot
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:

Google AdInline article slot
  • expect(page.getByText('Gotowe')).toBeVisible()
  • expect.poll na 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.

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);
}

// 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 waitForTimeout na expect.poll i expect.toPass dla dynamicznego oczekiwania.
  • Idempotencja przez X-Idempotency-Key eliminuje 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

Advertisement 728x90

Czytaj dalej