Back to Home

Stabilizing Playwright Tests Without Flakes

The article analyzes the causes of flakes in Playwright E2E tests and offers solutions: dynamic polling expect.poll, idempotency, multi-level mocks up to Pact, test data cleanup strategies. Practical code examples for stability in CI.

Fighting Flakes in Playwright: poll, mocks, contracts
Advertisement 728x90

Stabilizing Playwright E2E Tests: Flake-Free Strategies Without Hard Waits

In CI/CD pipelines, Playwright E2E tests often flake due to high load, network delays, and resource contention. Locally, powerful CPUs and low latency hide race conditions: the frontend renders the UI, but the backend hasn't committed the transaction to the database yet. In CI, conditions are harsher—tests catch inconsistent states and fail randomly.

The key to reliability: shift from static delays to dynamic polling, idempotent requests, and robust dependency isolation. Let's dive into techniques that slash flakiness at scale.

Dynamic Polling Over Rigid Timeouts

waitForTimeout(5000) is an anti-pattern. In 90% of cases, flakes stem from timing mismatches: the backend responds at 5001ms instead of the expected 5000. The fix? expect.poll, which checks the state until success or timeout.

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

Playwright auto-tunes polling intervals. For slow scenarios, bump test.setTimeout(60000), but don't tweak intervals manually.

The networkidle Trap and Better Alternatives

waitUntil: 'networkidle' waits 500ms without network activity. Issue: analytics, chat widgets, or pings break the silence, hanging the test until global timeout.

Rule: Wait for specific elements or API responses:

Google AdInline article slot
  • expect(page.getByText('Ready')).toBeVisible()
  • expect.poll on backend status

For retrying action blocks, use expect.toPass:

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

Idempotency to Shield Against Network Glitches

In distributed systems, retries on network hiccups create duplicates: the first POST succeeds, response is lost, retry duplicates the entity. Result? 400 errors or inconsistent states.

Solution: Generate an Idempotency-Key from method, URL, and 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 }
  });
}

Backend returns cached results by key. Retry-induced flakes vanish.

Mocks: Isolation Levels from Basic to Contract-Driven

Native Mocks with page.route

To isolate UI from backend:

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

await page.goto('/orders');
await expect(page.getByText('Something went wrong')).toBeVisible();

Limitation: Misses server-side requests via request fixture.

Infrastructure Mocks (WireMock)

Backend relies on external services (payments, SMS)? Spin up WireMock:

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

Mapping example (payment.json):

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

Tests stay independent of external uptime.

Contract Testing vs. Lying Mocks

Mocks lie: frontend expects order_id, backend sends orderId—tests pass green, prod crashes.

Consumer-Driven Contracts (Pact) lock expectations in JSON contracts:

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 verifies the contract in its CI. API changes get caught pre-deploy.

Test Data Hygiene

Tests clutter the DB—performance tanks, unique constraints cause flakes.

1. TTL with Background 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

Collect IDs in a queue on create, purge in global 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. Table Partitioning

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

Dropping partitions is O(1).

Key Takeaways:

  • Swap waitForTimeout for expect.poll and expect.toPass for smart waits.
  • Idempotency via X-Idempotency-Key kills retry duplicates.
  • Mock evolution: page.route → WireMock → Pact for true isolation.
  • TTL + cron/queues/partitions for clean data.
  • Stability is systemic: fix backend and infra alongside tests.

— Editorial Team

Advertisement 728x90

Read Next