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.

πŸ€” Sound familiar?
  • 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:

  1. getByRole('button', { name }) β€” semantic, accessible, refactor-proof.
  2. getByLabel, getByPlaceholder, getByText β€” for form fields and static content.
  3. getByTestId('...') β€” when no semantic anchor exists; pair with a data-testid in source.
  4. 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

ConcernPlaywrightCypress
Multi-browserChromium, Firefox, WebKit out of boxChromium + Firefox (WebKit experimental)
Multi-tab / multi-originFirst-classImproving but historically limited
ParallelizationBuilt-in worker modelCloud-only for free parallelism
Trace / debuggingPlaywright Trace Viewer β€” excellentCypress Studio + time-travel
API testingBuilt-in request fixtureHas 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.