Skip to content

Playwright E2E Testing

intermediate18 min read

Why Your E2E Tests Keep Breaking

You've written unit tests. You've tested your components in isolation with React Testing Library. Everything passes. You deploy, and the login flow is broken because the redirect middleware conflicts with the auth cookie. Nobody caught it because no test ever ran the full flow.

That's the gap end-to-end tests fill. They test what users actually do — clicking buttons, filling forms, navigating between pages, and seeing real results. The problem? E2E tests have historically been flaky, slow, and painful to maintain. Selenium required explicit waits everywhere. Cypress was single-browser and couldn't handle multiple tabs.

Playwright changed the game.

Mental Model

Think of Playwright as a robot user with perfect patience. It opens a real browser, navigates your app, and interacts with it exactly like a human would — except it automatically waits for elements to appear, animations to finish, and network requests to settle. You describe what you want to happen, and Playwright figures out when it's ready.

What Makes Playwright Different

Three things separate Playwright from everything that came before:

Auto-waiting. Every action — click, fill, assertion — automatically waits for the element to be visible, enabled, and stable before proceeding. No sleep(2000). No waitForElement. You write await page.click('button') and Playwright handles the timing. This single feature eliminates 80% of flaky tests.

Cross-browser out of the box. One test runs on Chromium, Firefox, and WebKit (Safari's engine). Not through a Selenium grid. Not through BrowserStack. Playwright ships its own browser binaries. Run npx playwright install and you've got three browser engines on your machine.

Multi-context architecture. Playwright can spin up multiple browser contexts — isolated sessions with their own cookies, storage, and cache — within a single browser instance. This makes parallel testing fast and authentication reuse trivial.

Quiz
What is the primary advantage of Playwright's auto-waiting over explicit waits?

Setup

Install Playwright in your project:

npm init playwright@latest

This creates a playwright.config.ts and a tests/ directory. Here's the config you'll actually want:

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests/e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: process.env.CI ? 'github' : 'html',
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
    { name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

Key decisions in this config:

  • fullyParallel: true runs tests in parallel by default. Each test gets its own browser context, so they can't interfere with each other.
  • trace: 'on-first-retry' records a trace only when a test fails and retries. Traces are expensive to generate but invaluable for debugging.
  • webServer starts your dev server automatically before tests run. In CI, it starts fresh. Locally, it reuses whatever's already running.

Test Structure

A Playwright test looks remarkably like a unit test:

import { test, expect } from '@playwright/test';

test('user can log in and see dashboard', async ({ page }) => {
  await page.goto('/login');

  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Password').fill('s3cur3-p@ss');
  await page.getByRole('button', { name: 'Sign in' }).click();

  await expect(page).toHaveURL('/dashboard');
  await expect(
    page.getByRole('heading', { name: 'Welcome back' })
  ).toBeVisible();
});

Notice what's not here: no explicit waits, no sleep, no waitForSelector. After clicking "Sign in", Playwright waits for navigation, then waits for the heading to appear. If the heading shows up in 50ms or 5 seconds, the test passes either way.

Grouping Tests

Use test.describe to group related tests:

test.describe('checkout flow', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/products');
  });

  test('add item to cart', async ({ page }) => {
    await page.getByRole('button', { name: 'Add to cart' }).first().click();
    await expect(page.getByTestId('cart-count')).toHaveText('1');
  });

  test('proceed to payment', async ({ page }) => {
    // ...
  });
});

Locators — The Right Way to Find Elements

Locators are Playwright's answer to the "how do I select this element?" question. And here's the key insight: Playwright's locator philosophy is identical to React Testing Library's. Both prioritize user-visible attributes over implementation details.

The Locator Priority

From best to worst:

// 1. Role-based (best) — mirrors how screen readers see the page
page.getByRole('button', { name: 'Submit' })
page.getByRole('heading', { name: 'Dashboard' })
page.getByRole('link', { name: 'Settings' })
page.getByRole('textbox', { name: 'Search' })

// 2. Label-based — great for form elements
page.getByLabel('Email address')
page.getByPlaceholder('Enter your email')

// 3. Text-based — for non-interactive text content
page.getByText('Welcome back')
page.getByText('No results found')

// 4. Test ID (escape hatch) — when nothing else works
page.getByTestId('complex-widget')

Why this order? Because getByRole('button', { name: 'Submit' }) will break if you remove the button or change its accessible name — both things that would break the user experience too. That's a good failure. But page.locator('.btn-primary.submit-cta.mt-4') would break if you changed a CSS class, which users don't care about. That's a bad failure.

Key Rules
  1. 1Use getByRole for interactive elements — buttons, links, headings, textboxes
  2. 2Use getByLabel for form fields — it tests accessibility for free
  3. 3Use getByText for static content assertions
  4. 4Use getByTestId only as a last resort when no accessible attribute exists
  5. 5Never use CSS selectors for test locators unless testing a CSS-specific behavior

Filtering and Chaining

Locators compose beautifully:

// Find the "Delete" button inside a specific row
page
  .getByRole('row', { name: /john@example/ })
  .getByRole('button', { name: 'Delete' });

// Find the second item in a list
page.getByRole('listitem').nth(1);

// Filter by contained text
page
  .getByRole('listitem')
  .filter({ hasText: 'Premium plan' })
  .getByRole('button', { name: 'Select' });
Quiz
You need to click the 'Edit' button inside a table row that contains 'alice@corp.com'. Which locator approach is best?

Actions

Playwright's action API covers everything a user can do:

// Click
await page.getByRole('button', { name: 'Save' }).click();
await page.getByRole('button', { name: 'Save' }).dblclick();

// Type
await page.getByLabel('Name').fill('Alice Johnson');
await page.getByLabel('Name').clear();
await page.getByLabel('Name').pressSequentially('Alice', { delay: 100 });

// Keyboard
await page.keyboard.press('Escape');
await page.getByRole('textbox').press('Enter');

// Select
await page.getByLabel('Country').selectOption('us');
await page.getByLabel('Country').selectOption({ label: 'United States' });

// Checkbox and radio
await page.getByLabel('Accept terms').check();
await page.getByLabel('Accept terms').uncheck();

// File upload
await page.getByLabel('Upload avatar').setInputFiles('photo.png');

// Drag and drop
await page.getByTestId('drag-handle').dragTo(page.getByTestId('drop-zone'));

The fill method clears the input first and then types the value. Use pressSequentially when you need to trigger input events for each keystroke — useful for testing autocomplete or search-as-you-type features.

Assertions

Playwright extends expect with web-specific matchers that auto-wait:

// Visibility
await expect(page.getByText('Success')).toBeVisible();
await expect(page.getByText('Error')).toBeHidden();

// Text content
await expect(page.getByRole('heading')).toHaveText('Dashboard');
await expect(page.getByRole('alert')).toContainText('saved');

// URL and title
await expect(page).toHaveURL('/dashboard');
await expect(page).toHaveURL(/\/dashboard\/\d+/);
await expect(page).toHaveTitle('My App - Dashboard');

// Count
await expect(page.getByRole('listitem')).toHaveCount(5);

// Attributes
await expect(page.getByRole('button')).toBeEnabled();
await expect(page.getByRole('button')).toBeDisabled();
await expect(page.getByRole('textbox')).toHaveValue('hello');

// CSS
await expect(page.getByTestId('box')).toHaveCSS('color', 'rgb(255, 0, 0)');
await expect(page.getByTestId('card')).toHaveClass(/active/);

Every expect assertion retries until it passes or the timeout expires (default 5 seconds). This is why Playwright tests feel so stable — assertions aren't a single point-in-time check. They're a patient observation.

Quiz
What happens when you write await expect(page.getByText('Loading...')).toBeHidden()?

Page Object Model

As your test suite grows, you'll notice the same interactions repeated across tests — logging in, navigating to a page, filling a form. The page object model (POM) extracts these into reusable classes:

// page-objects/LoginPage.ts
import { type Page, type Locator } from '@playwright/test';

export class LoginPage {
  private readonly emailInput: Locator;
  private readonly passwordInput: Locator;
  private readonly submitButton: Locator;

  constructor(private readonly page: Page) {
    this.emailInput = page.getByLabel('Email');
    this.passwordInput = page.getByLabel('Password');
    this.submitButton = page.getByRole('button', { name: 'Sign in' });
  }

  async goto() {
    await this.page.goto('/login');
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }
}
// page-objects/DashboardPage.ts
import { type Page, type Locator, expect } from '@playwright/test';

export class DashboardPage {
  private readonly heading: Locator;
  private readonly courseCards: Locator;

  constructor(private readonly page: Page) {
    this.heading = page.getByRole('heading', { name: 'Welcome back' });
    this.courseCards = page.getByTestId('course-card');
  }

  async expectLoaded() {
    await expect(this.heading).toBeVisible();
  }

  async expectCourseCount(count: number) {
    await expect(this.courseCards).toHaveCount(count);
  }
}

Now tests read like user stories:

import { test } from '@playwright/test';
import { LoginPage } from './page-objects/LoginPage';
import { DashboardPage } from './page-objects/DashboardPage';

test('authenticated user sees their courses', async ({ page }) => {
  const loginPage = new LoginPage(page);
  const dashboard = new DashboardPage(page);

  await loginPage.goto();
  await loginPage.login('user@example.com', 's3cur3-p@ss');
  await dashboard.expectLoaded();
  await dashboard.expectCourseCount(3);
});

The test no longer knows about CSS selectors, ARIA roles, or DOM structure. If the login form changes from email/password to magic link, you update LoginPage once. Every test using it stays green.

Page objects vs. fixtures — when to use which

Page objects work great for encapsulating page-specific interactions. But they require manual instantiation in every test. Playwright fixtures take this further — they let you inject pre-configured page objects (and other setup) automatically. Use page objects when you need simple encapsulation. Use fixtures (covered in the next section) when you need shared setup, teardown, or cross-cutting concerns like authentication.

Fixtures

Fixtures are Playwright's dependency injection system. They replace repetitive setup/teardown with declarative dependencies:

import { test as base, expect } from '@playwright/test';
import { LoginPage } from './page-objects/LoginPage';
import { DashboardPage } from './page-objects/DashboardPage';

type Fixtures = {
  loginPage: LoginPage;
  dashboardPage: DashboardPage;
};

export const test = base.extend<Fixtures>({
  loginPage: async ({ page }, use) => {
    const loginPage = new LoginPage(page);
    await use(loginPage);
  },
  dashboardPage: async ({ page }, use) => {
    const dashboard = new DashboardPage(page);
    await use(dashboard);
  },
});

export { expect };

Now tests declare what they need:

import { test, expect } from './fixtures';

test('dashboard loads after login', async ({ loginPage, dashboardPage }) => {
  await loginPage.goto();
  await loginPage.login('user@example.com', 's3cur3-p@ss');
  await dashboardPage.expectLoaded();
});

Fixtures are lazy — dashboardPage is only created if the test actually uses it. They also run setup/teardown in the right order automatically.

Authentication State Reuse

Logging in before every test is slow. If your login flow takes 2 seconds and you have 100 tests, that's over 3 minutes wasted on login alone. Playwright solves this with storage state.

The idea: log in once, save the cookies and local storage to a file, and reuse that state across tests.

// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';

const authFile = 'tests/.auth/user.json';

setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Password').fill('s3cur3-p@ss');
  await page.getByRole('button', { name: 'Sign in' }).click();

  await expect(page).toHaveURL('/dashboard');

  await page.context().storageState({ path: authFile });
});

Then configure it in playwright.config.ts:

export default defineConfig({
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },

    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'tests/.auth/user.json',
      },
      dependencies: ['setup'],
    },
  ],
});

The setup project runs first and saves the auth state. The chromium project depends on setup and loads the saved state into every browser context. Every test starts already logged in, instantly.

Warning

Add tests/.auth/ to your .gitignore. Storage state files contain session tokens and should never be committed to version control.

Multiple Roles

Need to test admin and regular user flows?

export default defineConfig({
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },

    {
      name: 'user-tests',
      use: {
        storageState: 'tests/.auth/user.json',
      },
      dependencies: ['setup'],
    },
    {
      name: 'admin-tests',
      use: {
        storageState: 'tests/.auth/admin.json',
      },
      dependencies: ['setup'],
    },
  ],
});

Create separate setup files for each role. Each project loads its own auth state.

Parallel Tests

Playwright runs tests in parallel by default. Each test file runs in its own worker process, and each test gets a fresh browser context. This means:

  • Tests cannot share state (no global variables, no shared database rows)
  • Tests can run in any order
  • Tests must set up their own data or use fixtures
// playwright.config.ts
export default defineConfig({
  fullyParallel: true,  // parallelize within files too
  workers: 4,           // number of parallel workers
});

If you need tests within a file to run sequentially (rare, but sometimes necessary for stateful flows):

test.describe.configure({ mode: 'serial' });

test.describe('multi-step wizard', () => {
  test('step 1: personal info', async ({ page }) => { /* ... */ });
  test('step 2: payment', async ({ page }) => { /* ... */ });
  test('step 3: confirmation', async ({ page }) => { /* ... */ });
});
What developers doWhat they should do
Sharing state between parallel tests via global variables or the database
Parallel tests run in isolated workers. Global variables are not shared, and database state leads to race conditions. Use API calls in beforeEach to seed data per test.
Each test creates its own test data and cleans up after itself
Using page.waitForTimeout(3000) to wait for elements to appear
Fixed timeouts are either too long (slow tests) or too short (flaky tests). Playwright assertions auto-retry with smart polling, making tests both faster and more reliable.
Use auto-waiting assertions like await expect(locator).toBeVisible()
Using CSS selectors like page.locator('.btn-submit') to find elements
CSS selectors couple tests to implementation details. Class names change during refactors. Role-based locators survive refactors and simultaneously verify your app is accessible.
Use semantic locators like page.getByRole('button', { name: 'Submit' })
Logging in through the UI in every single test
UI login adds seconds per test. With 100+ tests, this wastes minutes. Auth state reuse logs in once, saves cookies to a file, and injects them into every test context instantly.
Use storageState to reuse authenticated sessions across tests

Visual Comparisons

Playwright can take screenshots and compare them pixel-by-pixel against baseline images. This catches CSS regressions that functional tests miss — a shifted layout, a wrong color, a missing icon.

test('homepage matches visual baseline', async ({ page }) => {
  await page.goto('/');
  await expect(page).toHaveScreenshot('homepage.png');
});

test('pricing card renders correctly', async ({ page }) => {
  await page.goto('/pricing');
  const card = page.getByTestId('pro-plan');
  await expect(card).toHaveScreenshot('pro-plan-card.png');
});

The first time you run these tests, Playwright saves the screenshots as baselines. Subsequent runs compare against the baselines and fail if they differ beyond a configurable threshold:

await expect(page).toHaveScreenshot('homepage.png', {
  maxDiffPixelRatio: 0.01,  // allow 1% pixel difference
});

Update baselines when intentional visual changes are made:

npx playwright test --update-snapshots
Info

Visual comparisons are platform-sensitive — font rendering differs between macOS, Linux, and Windows. Run visual tests in a consistent environment (like a Docker container in CI) to avoid false positives.

Trace Viewer for Debugging

When a test fails, the first question is always "what happened?" The trace viewer gives you a time-travel debugger for your tests.

With trace: 'on-first-retry' in your config, Playwright records a trace when a test fails and retries. The trace captures:

  • Every action the test performed (clicks, navigations, fills)
  • DOM snapshots before and after each action
  • Network requests and responses
  • Console logs
  • Screenshots at each step

Open a trace:

npx playwright show-trace trace.zip

You can also view traces from the HTML reporter — click any failed test to see its trace inline.

Debugging During Development

For real-time debugging while writing tests:

# Run with the Playwright Inspector (step through actions)
npx playwright test --debug

# Run headed (see the browser)
npx playwright test --headed

# Use codegen to record actions
npx playwright codegen http://localhost:3000

The codegen tool opens a browser and records your actions as Playwright code. It's fantastic for prototyping tests — click around your app, and Playwright generates the locators and assertions for you. The generated code isn't always perfect, but it's a great starting point.

Quiz
A test fails in CI but passes locally. What is the most effective first debugging step?

CI Configuration

E2E tests should run on every pull request. Here's a GitHub Actions workflow:

name: E2E Tests
on:
  pull_request:
    branches: [main]

jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps chromium

      - name: Build application
        run: npm run build

      - name: Run E2E tests
        run: npx playwright test --project=chromium

      - name: Upload test artifacts
        uses: actions/upload-artifact@v4
        if: ${{ !cancelled() }}
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 14

Key CI considerations:

  • Install only the browsers you need (chromium instead of all three) to speed up the pipeline.
  • Upload the HTML report as an artifact so you can inspect failures without re-running.
  • Use if: ${{ !cancelled() }} for artifact upload so reports are available even when tests fail.
  • Set retries: 2 in your config for CI to handle transient infrastructure issues.

Sharding for Large Test Suites

When your test suite grows beyond 5-10 minutes, split it across multiple CI machines:

jobs:
  e2e:
    strategy:
      matrix:
        shard: [1/4, 2/4, 3/4, 4/4]
    steps:
      - name: Run tests
        run: npx playwright test --shard=${{ matrix.shard }}

This runs your tests across 4 parallel machines, cutting execution time by roughly 75%.

Writing Tests That Last

The real skill isn't writing tests — it's writing tests that don't become a maintenance burden. Here are the patterns that hold up at scale:

Test user-visible behavior, not implementation. A test should describe what a user does and what they see. "User fills email, clicks submit, sees success message." Not "the form component dispatches an action that updates the Redux store with a loading flag."

One assertion per behavior. Each test should verify one user flow. If a test has 20 assertions, it's testing too many things and will be hard to debug when it fails.

Avoid coupling tests to specific data. Use regex matchers and partial text matching instead of exact values when the exact text might change:

// Brittle — breaks if you add a last name field
await expect(page.getByRole('heading')).toHaveText('Welcome, Alice!');

// Resilient — survives text changes
await expect(page.getByRole('heading')).toContainText('Welcome');

Name tests as user stories. A test name should describe the scenario in plain English:

// Descriptive — tells you what broke
test('user with expired subscription sees upgrade prompt', async ({ page }) => {
  // ...
});

// Useless — tells you nothing
test('test subscription', async ({ page }) => {
  // ...
});
Key Rules
  1. 1Test what users see and do, not how your code is structured internally
  2. 2Use auto-waiting assertions instead of explicit waits or timeouts
  3. 3Prefer role-based locators for interactive elements and label-based locators for form fields
  4. 4Reuse authentication state with storageState instead of logging in per test
  5. 5Record traces on failure in CI — they are the fastest path to diagnosing broken tests

Putting It All Together

Here's a realistic test for an e-commerce checkout flow that demonstrates the patterns covered:

import { test, expect } from './fixtures';

test.describe('checkout', () => {
  test('complete purchase flow', async ({ page, authenticatedUser }) => {
    await page.goto('/products');

    await page
      .getByRole('article')
      .filter({ hasText: 'Pro Plan' })
      .getByRole('button', { name: 'Add to cart' })
      .click();

    await expect(page.getByTestId('cart-count')).toHaveText('1');

    await page.getByRole('link', { name: 'Cart' }).click();
    await expect(page).toHaveURL('/cart');

    await page.getByRole('button', { name: 'Checkout' }).click();

    await page.getByLabel('Card number').fill('4242424242424242');
    await page.getByLabel('Expiry').fill('12/28');
    await page.getByLabel('CVC').fill('123');

    await page.getByRole('button', { name: 'Pay now' }).click();

    await expect(page.getByRole('heading', { name: /order confirmed/i })).toBeVisible();
    await expect(page).toHaveURL(/\/orders\/\w+/);
  });
});

No waits. No sleeps. No CSS selectors. Just a clear description of what a user does and what they expect to see.

Quiz
In a Playwright config with fullyParallel: true, what happens when two tests in the same file both modify the same database record?