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.
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:
expect(page.getByText('Ready')).toBeVisible()expect.pollon 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.
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
waitForTimeoutforexpect.pollandexpect.toPassfor smart waits. - Idempotency via
X-Idempotency-Keykills 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
No comments yet.