EmpireUI
Get Pro
← Blog9 min read#playwright#e2e#react

Playwright E2E Tests for React Apps: Setup, Fixtures, CI

Learn how to set up Playwright for React apps with reusable fixtures, page objects, and a bulletproof CI pipeline that won't slow your team down.

Code editor showing JavaScript test file on dark monitor

Why Playwright Over Cypress in 2026

Cypress had its moment. Honestly, it was the right tool for a lot of teams around 2020–2022 — quick setup, a decent test runner, decent DX. But Playwright has overtaken it on almost every metric that matters: multi-browser support, parallel execution, mobile emulation that doesn't require a paid tier, and a fixture system that doesn't make you want to quit programming.

The turning point for most React teams is when they hit Cypress's iframe limitations or need to test a Safari-specific layout bug. Playwright handles both without drama. It runs Chromium, Firefox, and WebKit from a single test suite, which means your npm run test:e2e on CI actually catches the Safari flex-gap bug before your users do.

That said, this isn't a Playwright-vs-Cypress flamefest. If your app already has 200 Cypress tests and everything's working, don't migrate for sport. But if you're starting fresh — or your Cypress suite takes 18 minutes on CI — you're reading the right article.

One more thing — Playwright's codegen tool (npx playwright codegen http://localhost:3000) is genuinely useful for scaffolding tests quickly. It records your clicks and generates real test code. Not production-ready code, but a solid 60% starting point.

Installing and Configuring Playwright for a React App

Assuming you've got a React app running — Vite, Next.js, CRA, doesn't matter — installation takes about 90 seconds. Run npm init playwright@latest in your project root. It'll ask you some questions: where to put tests, whether to add a GitHub Actions file, which browsers to install. Pick tests/e2e for the folder and say yes to GitHub Actions.

npm init playwright@latest
# or if you prefer pnpm:
pnpm dlx create-playwright

Your playwright.config.ts is where most of the real configuration lives. The two things you'll customize immediately are baseURL and webServer. Setting baseURL to http://localhost:3000 means your tests can use relative paths — page.goto('/') instead of page.goto('http://localhost:3000/'). The webServer block tells Playwright to spin up your dev server before running tests, so you don't have to manage that process manually.

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

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

Worth noting: retries: 2 on CI is a pragmatic decision, not a sign of weakness. Flaky tests on CI kill developer confidence faster than almost anything else. Two retries with a trace: 'on-first-retry' config means you get a full timeline of exactly what failed without drowning in noise on the first try.

Writing Your First Real Test

A lot of Playwright tutorials show you page.goto('/') and expect(page).toHaveTitle(...) and call it a day. That's fine for a smoke test. But the moment you're testing a real React app — forms, auth flows, dynamic data — you need a bit more structure.

Here's a login form test that's actually representative of what you'd write on a real project. It navigates to a page, fills out a form, submits it, and asserts the post-login state. Nothing fancy — just the pattern you'll use a hundred times.

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

test('user can log in with valid credentials', async ({ page }) => {
  await page.goto('/login');

  await page.getByLabel('Email').fill('test@example.com');
  await page.getByLabel('Password').fill('password123');
  await page.getByRole('button', { name: 'Sign in' }).click();

  await expect(page).toHaveURL('/dashboard');
  await expect(page.getByText('Welcome back')).toBeVisible();
});

test('shows error on invalid credentials', async ({ page }) => {
  await page.goto('/login');

  await page.getByLabel('Email').fill('wrong@example.com');
  await page.getByLabel('Password').fill('wrongpassword');
  await page.getByRole('button', { name: 'Sign in' }).click();

  await expect(page.getByRole('alert')).toContainText('Invalid credentials');
});

Notice how the selectors work: getByLabel, getByRole, getByText. These are Playwright's recommended locator strategies, and they map directly to accessible attributes. They're also significantly more resilient than CSS selectors — renaming a class from btn-primary to button--primary won't break your tests. That's the whole point.

In practice, if you find yourself writing page.locator('.sidebar > div:nth-child(3)'), stop. That test will break on the next design change. Use getByRole or getByTestId instead, and add data-testid attributes to your React components when the semantic selectors aren't enough.

Fixtures: The Feature That Changes Everything

If you've only written Playwright tests without fixtures, you're missing 40% of the reason the tool is worth using. Fixtures are Playwright's dependency injection system for tests — you define reusable setup/teardown logic once, name it, and then every test that needs it just declares it as a parameter. No beforeEach soup.

The classic use case is an authenticated page object. You don't want every test to go through the login flow — that's slow, brittle, and wastes CI minutes. Instead, you write a fixture that authenticates once, saves the storage state (cookies, localStorage) to a file, and then every test that needs auth just loads from that file.

// tests/e2e/fixtures.ts
import { test as base, Page } from '@playwright/test';

type Fixtures = {
  authenticatedPage: Page;
};

export const test = base.extend<Fixtures>({
  authenticatedPage: async ({ browser }, use) => {
    const context = await browser.newContext({
      storageState: 'tests/e2e/.auth/user.json',
    });
    const page = await context.newPage();
    await use(page);
    await context.close();
  },
});

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

setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('test@example.com');
  await page.getByLabel('Password').fill('password123');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await page.waitForURL('/dashboard');
  await page.context().storageState({ path: 'tests/e2e/.auth/user.json' });
});

Now in your playwright.config.ts, you add a setup project as a dependency of your main test projects. Playwright runs the auth setup once per worker, saves the state, and every subsequent test starts from an already-authenticated context. For a suite with 50 protected-route tests, this can shave 3–4 minutes off your CI run.

Page Object Model for Larger Test Suites

Fixtures handle setup. Page Objects handle repetition in selectors and actions. If you've got a navigation menu that 12 different tests interact with, you don't want .getByRole('navigation').getByText('Dashboard') scattered across 12 files. Centralize it.

// tests/e2e/pages/DashboardPage.ts
import { Page, Locator } from '@playwright/test';

export class DashboardPage {
  readonly page: Page;
  readonly heading: Locator;
  readonly statsCards: Locator;
  readonly createButton: Locator;

  constructor(page: Page) {
    this.page = page;
    this.heading = page.getByRole('heading', { name: 'Dashboard' });
    this.statsCards = page.getByTestId('stat-card');
    this.createButton = page.getByRole('button', { name: 'Create new' });
  }

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

  async clickCreate() {
    await this.createButton.click();
  }

  async getStatCount() {
    return this.statsCards.count();
  }
}

Then in your tests you import the class and call its methods. The selector logic lives in one place, and when the design team renames the heading from "Dashboard" to "Overview", you fix it in one file instead of seventeen.

Quick aside: don't over-engineer this. You don't need a Page Object for every single page on day one. Start with the pages that multiple tests touch — your auth flow, your main dashboard, your primary form. Build out from there as your test suite grows.

Wiring Up CI with GitHub Actions

The GitHub Actions config that npm init playwright@latest generates is fine as a starting point, but it has some rough edges for a real team. Here's a more production-ready version that caches dependencies, runs browsers in parallel, and uploads test reports as artifacts.

# .github/workflows/e2e.yml
name: Playwright E2E Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    timeout-minutes: 30
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Install Playwright browsers
        run: npx playwright install --with-deps chromium firefox webkit
      
      - name: Build app
        run: npm run build
      
      - name: Run Playwright tests
        run: npx playwright test
        env:
          CI: true
          BASE_URL: http://localhost:3000
      
      - uses: actions/upload-artifact@v4
        if: ${{ !cancelled() }}
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 14

The if: ${{ !cancelled() }} on the artifact upload is important. Without it, if your tests fail, the upload step gets skipped and you have no report to look at. You want the HTML report uploaded whether tests pass or fail — that's when you actually need it.

One optimization worth making early: cache the Playwright browser binaries. The browser install step downloads ~300MB on every run by default. Add a cache step keyed on your Playwright version and you'll cut 90 seconds off every CI run.

- name: Cache Playwright browsers
  uses: actions/cache@v4
  id: playwright-cache
  with:
    path: ~/.cache/ms-playwright
    key: playwright-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}

- name: Install Playwright browsers
  if: steps.playwright-cache.outputs.cache-hit != 'true'
  run: npx playwright install --with-deps

Look, your CI pipeline is only as useful as your test suite's signal-to-noise ratio. If every PR triggers 6 minutes of tests that flake 30% of the time, developers start ignoring failures. Invest the time to make your Playwright suite deterministic. Use test.step() to add structure to long tests, always wait for network idle before asserting, and set a actionTimeout of around 10000ms in your config so flaky races don't silently pass on fast machines and fail on slow CI runners.

Testing UI Components With Visual Assertions

Playwright's visual testing via toHaveScreenshot() is underrated. It's not a replacement for functional assertions — you still need to verify that a button submits a form, not just that it looks right. But for component-level visual regressions, it's a low-effort way to catch the CSS change that shifted your glassmorphism components by 4px and made the blur radius wrong.

test('dashboard card matches snapshot', async ({ page }) => {
  await page.goto('/dashboard');
  await page.waitForLoadState('networkidle');
  
  const card = page.getByTestId('stat-card').first();
  await expect(card).toHaveScreenshot('stat-card.png', {
    maxDiffPixels: 50,
  });
});

The first run generates baseline screenshots, which you commit to your repo. Every subsequent run diffs against the baseline. maxDiffPixels: 50 gives you a small tolerance for antialiasing differences across platforms — without it, you'll get false failures on Linux CI vs Mac local.

Worth noting: visual tests are the most brittle tests in your suite. Any intentional design change requires regenerating baselines with npx playwright test --update-snapshots. That's fine — it's a deliberate action. The friction is the feature. It forces you to consciously acknowledge visual changes before they land. If you're building a component library (or pulling from one like Empire UI), this pattern is especially valuable because design changes in shared components ripple everywhere.

FAQ

Do I need to run a dev server before running Playwright tests?

Not manually — Playwright's webServer config in playwright.config.ts handles that. It starts your server, waits for it to be ready, then runs tests. On CI, you'd typically build and serve a production build instead.

How do I handle authentication in Playwright without repeating the login flow in every test?

Use a setup project that runs once, saves storageState to a JSON file, and then load that file in a fixture. Your authenticated tests skip the login flow entirely and start from an already-logged-in context.

Playwright tests are slow on CI — what's the fastest fix?

First, cache your browser binaries using actions/cache keyed on the Playwright version. Second, set fullyParallel: true and increase workers. Third, audit your test suite for unnecessary waitForTimeout calls — replace them with waitForSelector or waitForLoadState.

Should I use Playwright for component testing or stick with Vitest/Testing Library?

Use both for different things. Vitest + Testing Library for unit and component logic tests — they're faster and run in Node. Playwright for integration and E2E flows that need a real browser. They complement each other, not compete.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Vitest + React Testing Library: Full Setup From ZeroStorybook 8 with React: Setup, Stories and Visual TestingCypress vs Playwright in 2026: E2E Testing ShowdownTesting a Design System: Visual, Unit, and Accessibility Tests