Playwright Made E2E Testing Tolerable. Don't Squander It.
For a decade, end-to-end tests were synonymous with βthe flaky suiteβ. Playwright changed that with auto-waiting, web-first assertions, browser context isolation, and trace viewers. The remaining flakiness today is almost always self-inflicted: hardcoded sleeps, shared state, fragile selectors. Fix those and an E2E suite becomes a confidence machine.
- Your E2E suite has a βre-run failedβ button that everyone presses
- Tests use
waitForTimeout(2000)as a workaround - Selectors look like
div.css-xj9q12 > span:nth-child(3) - One test leaves data that breaks the next one
Playwright's auto-waiting, role/label-based locators, network interception, and per-test browser context eliminate the four classic sources of flakiness.
Auto-Waiting Is the Default β Stop Helping It
Every Playwright action waits for the element to be attached, visible, stable, enabled, and receiving events before acting. You do not need waitForSelector before click. You do not need waitForTimeout after navigation. Web-first assertions (expect(locator).toBeVisible()) also auto-retry until they pass or time out.
import { test, expect } from '@playwright/test';
test('user can sign up', async ({ page }) => {
await page.goto('/signup');
await page.getByLabel('Email').fill('ada@example.com');
await page.getByLabel('Password').fill('correct horse battery');
await page.getByRole('button', { name: 'Create account' }).click();
// Auto-retries until visible or test timeout. No sleeps needed.
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
await expect(page).toHaveURL(/\/dashboard$/);
});Locators That Survive a Redesign
Prefer locators that match what a user (or assistive tech) sees. Order of preference:
getByRole('button', { name })β semantic, accessible, refactor-proof.getByLabel,getByPlaceholder,getByTextβ for form fields and static content.getByTestId('...')β when no semantic anchor exists; pair with adata-testidin source.- CSS selectors β last resort.
Page Object Model β Light Version
Full POM ceremonies (one class per page, getters for every element) tend to ossify. The light POM that scales: a small per-page module exporting locators and the few cross-cutting actions that aren't one-liners.
// pages/login.ts
import { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly email: Locator;
readonly password: Locator;
readonly submit: Locator;
constructor(public readonly page: Page) {
this.email = page.getByLabel('Email');
this.password = page.getByLabel('Password');
this.submit = page.getByRole('button', { name: 'Log in' });
}
async loginAs(user: { email: string; password: string }) {
await this.page.goto('/login');
await this.email.fill(user.email);
await this.password.fill(user.password);
await this.submit.click();
}
}Test Isolation Done Right
flowchart LR
R[Test runner] -->|new BrowserContext| C1[Context 1<br/>cookies/storage]
R -->|new BrowserContext| C2[Context 2<br/>cookies/storage]
R -->|new BrowserContext| C3[Context 3<br/>cookies/storage]
C1 -.->|all isolated| API[(API + DB)]
C2 -.-> API
C3 -.-> API
Each Playwright test gets its own BrowserContext β cookies, localStorage, indexedDB scoped to the test. Combine with per-test data in the API/DB (unique emails, scoped tenants) and you get safe parallelism for free.
Authentication State Reuse
Logging in via the UI on every test is wasteful. Sign in once in a setup project, save the storage state, reuse across tests.
// playwright.config.ts
projects: [
{
name: 'setup',
testMatch: /.*\.setup\.ts/,
},
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
],
// auth.setup.ts
import { test as setup } from '@playwright/test';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.TEST_USER!);
await page.getByLabel('Password').fill(process.env.TEST_PASS!);
await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL('/dashboard');
await page.context().storageState({ path: 'playwright/.auth/user.json' });
});Network Interception
Stub flaky third-party calls without leaving the browser. page.route intercepts requests by URL pattern; you can mock, modify, or assert.
test('shows error when payments service is down', async ({ page }) => {
await page.route('**/api/payments/charge', route =>
route.fulfill({ status: 503, body: JSON.stringify({ error: 'unavailable' }) }));
await page.goto('/checkout');
await page.getByRole('button', { name: 'Pay' }).click();
await expect(page.getByRole('alert')).toContainText('try again');
});Cypress vs Playwright in 2026
| Concern | Playwright | Cypress |
|---|---|---|
| Multi-browser | Chromium, Firefox, WebKit out of box | Chromium + Firefox (WebKit experimental) |
| Multi-tab / multi-origin | First-class | Improving but historically limited |
| Parallelization | Built-in worker model | Cloud-only for free parallelism |
| Trace / debugging | Playwright Trace Viewer β excellent | Cypress Studio + time-travel |
| API testing | Built-in request fixture | Has it; less common pattern |
Trace Viewer β Your Flake Killer
Run with --trace=retain-on-failure and you get a single .zipper failed test that opens in the Trace Viewer: full DOM snapshots, network log, console, action timeline. Most flakes that look like βthe click missedβ resolve in five minutes of trace review.
CI Setup That Stays Fast
- Shard across N workers in GitHub Actions matrix; merge HTML reports with
playwright merge-reports. - Cache the browser binaries β they're big.
- Only run cross-browser on PR or nightly; commits run chromium only.
- Block the merge on first-attempt pass; configure 1 retry on CI only for true cosmic-ray flakes; track retry rate.
# .github/workflows/e2e.yml
strategy:
fail-fast: false
matrix:
shard: [1/4, 2/4, 3/4, 4/4]
steps:
- run: npx playwright install --with-deps chromium
- run: npx playwright test --shard=${{ matrix.shard }}
- if: failure()
uses: actions/upload-artifact@v4
with: { name: trace-${{ matrix.shard }}, path: test-results/ }Pitfalls
One mega-test per user journey
A test that signs up, configures, buys, and refunds in 90 steps is slow and unactionable when it breaks. Split by phase; share state via setup hooks, not by chaining.
Asserting on text that changes weekly
Marketing copy moves. Status messages get rewritten. Assert on roles, data-testids, URL changes, or stable substrings β not the full paragraph of welcome text.
Letting flake retries hide real bugs
Retrying flaky tests is fine; ignoring them isn't. Track flake rate per test; quarantine tests over a threshold and fix them. Otherwise the suite slowly becomes a coin toss.

