Type something to search...
End-to-End Testing React Apps with Playwright

End-to-End Testing React Apps with Playwright

Unit and component tests tell you that pieces work. They don't tell you that a user can actually sign up, add something to a cart, and check out in a real browser, with your real router, real CSS, real API, and real build output. Those flows break in ways component tests can't see: a CORS header missing from the API, a route that 404s only in production builds, a modal that covers the submit button on smaller screens.

Playwright runs your app in real Chromium, Firefox, and WebKit browsers and drives it like a user. It waits for elements automatically, isolates every test in a fresh browser context, records traces you can step through when something fails, and runs tests in parallel by default. Those features address the two classic complaints about end-to-end tests: they're slow and they're flaky.

This guide sets up Playwright for a Vite + React app and covers the things you need on a real project: writing tests with role-based locators, assertions that wait, logging in once and reusing the session, mocking network calls where it makes sense, organizing tests with page objects and fixtures, debugging failures, and running everything in CI.

Installing Playwright

From your project root, run the initializer:

npm init playwright@latest

It asks a few questions (TypeScript, test folder name, whether to add a GitHub Actions workflow) and then installs @playwright/test, downloads browser binaries, and creates:

playwright.config.ts
tests/
  example.spec.ts
.github/workflows/playwright.yml   # if you chose it

Add a couple of scripts:

{
  "scripts": {
    "e2e": "playwright test",
    "e2e:ui": "playwright test --ui"
  }
}

If you also use Vitest, make sure it doesn't pick up Playwright files. Either keep end-to-end tests in a folder like e2e/ and set Vitest's include to src/**/*.test.tsx, or name them *.spec.ts and exclude that pattern.

Configuring Playwright for a React App

Replace the generated config with something tuned for a Vite app:

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

const PORT = 4173;

export default defineConfig({
  testDir: "./e2e",
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: process.env.CI ? [["html", { open: "never" }], ["github"]] : "list",

  use: {
    baseURL: `http://localhost:${PORT}`,
    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", use: { ...devices["Pixel 7"] } },
  ],

  webServer: {
    command: `npm run build && npm run preview -- --port ${PORT} --strictPort`,
    url: `http://localhost:${PORT}`,
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
});

The important parts:

  • webServer starts your app before the tests run and stops it afterwards. Testing the production build with vite preview catches build-only problems. Locally, reuseExistingServer lets you keep a server running between runs.
  • baseURL lets tests call page.goto("/cart") instead of repeating the host.
  • trace: "on-first-retry" records a full trace when a test fails and is retried, which is the most useful debugging artifact Playwright produces.
  • projects run the same tests in several browsers and device profiles. Locally, you can run one with npx playwright test --project=chromium.
  • forbidOnly fails CI if someone commits a test.only.

If your Vite app is new, building React apps with Vite covers the build and preview scripts this config relies on.

Writing Your First Test

Every test receives a page, a fresh tab in an isolated browser context. No cookies, storage, or cache leak between tests.

// e2e/home.spec.ts
import { expect, test } from "@playwright/test";

test("home page links to pricing", async ({ page }) => {
  await page.goto("/");

  await expect(page).toHaveTitle(/Acme/);
  await expect(page.getByRole("heading", { level: 1 })).toHaveText(
    "Ship faster with Acme",
  );

  await page.getByRole("link", { name: "Pricing" }).click();

  await expect(page).toHaveURL("/pricing");
  await expect(page.getByRole("heading", { name: "Plans" })).toBeVisible();
});

Run it with npm run e2e. Playwright builds the app, starts the preview server, and runs the test in every configured browser.

Locators: Finding Elements Like a User

A locator describes how to find an element. It's lazy: Playwright resolves it every time you act on it or assert on it, so it always refers to the current DOM, even after React re-renders.

Prefer user-facing locators, in roughly this order:

page.getByRole("button", { name: "Add to cart" });
page.getByLabel("Email address");
page.getByPlaceholder("Search products");
page.getByText("Free shipping on orders over €50");
page.getByAltText("Product photo");
page.getByTestId("cart-count"); // data-testid, last resort

These match how people and assistive technology find things, and they survive markup and class name changes. CSS selectors like .btn-primary > span break on the next redesign.

Locators can be chained and filtered to narrow the search:

const row = page.getByRole("row").filter({ hasText: "Mechanical Keyboard" });
await row.getByRole("button", { name: "Remove" }).click();

const cart = page.getByRole("region", { name: "Shopping cart" });
await expect(cart.getByRole("listitem")).toHaveCount(2);

Locators are strict. If getByRole("button", { name: "Delete" }) matches three buttons and you call click(), Playwright throws instead of guessing. That's a feature. It forces you to target the element you mean.

To generate locators interactively, run npx playwright codegen http://localhost:5173. It opens a browser, records your clicks, and writes test code with role-based locators.

Auto-Waiting and Web-First Assertions

The biggest source of flaky end-to-end tests is timing: clicking a button before it's enabled, or checking text before a fetch resolves. Playwright handles this in two ways.

First, actions auto-wait. Before click(), Playwright waits until the element is attached, visible, stable (not animating), enabled, and able to receive the click. You almost never need manual waits.

Second, web-first assertions retry. await expect(locator).toHaveText("3 items") re-checks the locator until it matches or the timeout (5 seconds by default) expires:

await page.getByRole("button", { name: "Add to cart" }).click();
await expect(page.getByTestId("cart-count")).toHaveText("1");
await expect(page.getByRole("status")).toContainText("Added to cart");
await expect(page.getByRole("button", { name: "Checkout" })).toBeEnabled();

Avoid patterns that defeat this:

// Bad: reads the value once, no retry
expect(await page.getByTestId("cart-count").textContent()).toBe("1");

// Bad: arbitrary sleep, slow and still flaky
await page.waitForTimeout(2000);

// Good: retries until it matches
await expect(page.getByTestId("cart-count")).toHaveText("1");

Testing a Realistic Flow

Here's a full checkout test that fills a form, submits it, and verifies the result:

// e2e/checkout.spec.ts
import { expect, test } from "@playwright/test";

test("guest can check out a single item", async ({ page }) => {
  await page.goto("/products/mechanical-keyboard");
  await page.getByRole("button", { name: "Add to cart" }).click();
  await page.getByRole("link", { name: /cart/i }).click();

  await expect(page.getByRole("listitem")).toHaveCount(1);
  await page.getByRole("button", { name: "Checkout" }).click();

  await page.getByLabel("Email").fill("ada@example.com");
  await page.getByLabel("Full name").fill("Ada Lovelace");
  await page.getByLabel("Address").fill("12 Analytical Way");
  await page.getByLabel("Country").selectOption("Germany");
  await page.getByRole("checkbox", { name: "I accept the terms" }).check();

  await page.getByRole("button", { name: "Place order" }).click();

  await expect(page).toHaveURL(/\/orders\/\w+/);
  await expect(page.getByRole("heading", { name: "Thank you for your order" })).toBeVisible();
});

The test reads like a description of what the user does. If the checkout form moves to a multi-step layout or switches form libraries, the test needs minimal changes.

Logging In Once With Storage State

Most apps require authentication, and logging in through the UI in every test is slow. Playwright's storage state saves cookies and localStorage after one login, and other tests start already signed in.

Create a setup file:

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

const authFile = "playwright/.auth/user.json";

setup("authenticate", async ({ page }) => {
  await page.goto("/login");
  await page.getByLabel("Email").fill(process.env.E2E_USER_EMAIL!);
  await page.getByLabel("Password").fill(process.env.E2E_USER_PASSWORD!);
  await page.getByRole("button", { name: "Sign in" }).click();

  await expect(page.getByRole("button", { name: "Account menu" })).toBeVisible();
  await page.context().storageState({ path: authFile });
});

Then register it as a project that other projects depend on:

// playwright.config.ts (projects section)
projects: [
  { name: "setup", testMatch: /.*\.setup\.ts/ },
  {
    name: "chromium",
    use: {
      ...devices["Desktop Chrome"],
      storageState: "playwright/.auth/user.json",
    },
    dependencies: ["setup"],
  },
],

Add playwright/.auth to .gitignore, since the file contains session tokens. Tests that must run logged out can override it:

test.use({ storageState: { cookies: [], origins: [] } });

If your tokens live in memory rather than cookies or localStorage, storage state can't capture them. The post on protected routes and authentication flows in React discusses where auth state usually lives.

Mocking and Inspecting Network Requests

End-to-end tests are most valuable against a real backend. Still, some scenarios are hard to produce on demand: a payment provider outage, a slow response, or an empty account. page.route intercepts requests from the page:

test("shows an error banner when the products API fails", async ({ page }) => {
  await page.route("**/api/products", (route) =>
    route.fulfill({ status: 500, json: { message: "Internal error" } }),
  );

  await page.goto("/products");
  await expect(page.getByRole("alert")).toHaveText(/couldn't load products/i);
});

test("renders an empty state", async ({ page }) => {
  await page.route("**/api/products", (route) => route.fulfill({ json: [] }));

  await page.goto("/products");
  await expect(page.getByText("No products found")).toBeVisible();
});

You can also modify a real response instead of replacing it:

await page.route("**/api/user", async (route) => {
  const response = await route.fetch();
  const user = await response.json();
  await route.fulfill({ response, json: { ...user, plan: "free" } });
});

Or wait for a request and check what was sent:

const requestPromise = page.waitForRequest(
  (req) => req.url().endsWith("/api/orders") && req.method() === "POST",
);
await page.getByRole("button", { name: "Place order" }).click();
const request = await requestPromise;
expect(request.postDataJSON()).toMatchObject({ items: [{ sku: "KB-01", qty: 1 }] });

Start waiting before the click, as shown, so a fast response can't slip past.

For component-level tests, network-level mocking with MSW is usually a better fit. See mocking API calls in React tests with MSW.

Organizing Tests With Page Objects and Fixtures

As your suite grows, the same locators and steps repeat across files. A page object collects them in one class:

// e2e/pages/cart-page.ts
import { expect, type Locator, type Page } from "@playwright/test";

export class CartPage {
  readonly items: Locator;
  readonly checkoutButton: Locator;

  constructor(private readonly page: Page) {
    this.items = page.getByRole("region", { name: "Shopping cart" }).getByRole("listitem");
    this.checkoutButton = page.getByRole("button", { name: "Checkout" });
  }

  async goto() {
    await this.page.goto("/cart");
  }

  async removeItem(name: string) {
    await this.items.filter({ hasText: name }).getByRole("button", { name: "Remove" }).click();
  }

  async expectItemCount(count: number) {
    await expect(this.items).toHaveCount(count);
  }
}

Fixtures inject page objects into tests, so each test declares what it needs:

// e2e/fixtures.ts
import { test as base } from "@playwright/test";
import { CartPage } from "./pages/cart-page";

type Fixtures = { cartPage: CartPage };

export const test = base.extend<Fixtures>({
  cartPage: async ({ page }, use) => {
    await use(new CartPage(page));
  },
});

export { expect } from "@playwright/test";
// e2e/cart.spec.ts
import { test } from "./fixtures";

test("removing an item updates the cart", async ({ cartPage }) => {
  await cartPage.goto();
  await cartPage.expectItemCount(2);
  await cartPage.removeItem("Mechanical Keyboard");
  await cartPage.expectItemCount(1);
});

Keep page objects thin. They should hold locators and common actions, not hide assertions that belong in the test.

Accessibility Checks

The @axe-core/playwright package runs axe in the real browser, which catches issues like color contrast that jsdom can't evaluate:

npm install -D @axe-core/playwright
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";

test("checkout page has no serious a11y violations", async ({ page }) => {
  await page.goto("/checkout");
  const results = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa"]).analyze();
  expect(results.violations).toEqual([]);
});

Debugging Failures

Playwright has excellent debugging tools:

  • UI mode (npx playwright test --ui) shows every test with a timeline, DOM snapshots for each step, and a watch mode.
  • Debug mode (npx playwright test --debug) opens the inspector and pauses before each action. Add await page.pause() to stop at a specific line.
  • Trace viewer opens a recorded trace with npx playwright show-trace trace.zip. In CI, the HTML report links to traces for failed tests, showing the DOM, console, and network at every step.

When a test fails only in CI, download the report artifact and open the trace. It almost always shows what was on screen when the assertion failed.

Running in CI

The generated GitHub Actions workflow is a good starting point. The essential steps:

npm ci
npx playwright install --with-deps
npx playwright test

--with-deps installs the system libraries browsers need on Linux. Upload playwright-report/ as an artifact so you can inspect failures. For large suites, shard across machines with npx playwright test --shard=1/4 and merge the reports afterwards.

Best Practices for Playwright Tests

  • Test user journeys, not components. Leave edge cases of individual components to Vitest. Use end-to-end tests for critical flows like sign-up, checkout, and core features.
  • Use role-based locators. They're resilient and double as an accessibility check.
  • Never use fixed sleeps. Rely on auto-waiting actions and retrying expect assertions.
  • Keep tests independent. Each test should set up its own data and not rely on another test running first.
  • Seed data through the API. Creating test data via API calls in beforeEach is far faster than clicking through the UI.
  • Reuse authentication. Log in once in a setup project and share the storage state.
  • Turn on traces. trace: "on-first-retry" makes CI failures debuggable without reproducing them locally.

Frequently Asked Questions (FAQ) About Playwright for React

Both are solid. Playwright supports Chromium, Firefox, and WebKit, runs tests in parallel for free, handles multiple tabs and origins, and has a fast, lightweight architecture. Cypress has a polished interactive runner and a large plugin ecosystem. For new projects, Playwright is a strong default.

Prefer a production build served with vite preview or your real server. It catches problems that only appear after bundling and minification, and it's faster than the dev server once built. Use the dev server locally if you want quicker iteration while writing tests.

Use web-first assertions that retry, never fixed timeouts, and locators based on roles and labels. Keep tests isolated with their own data, avoid depending on test order, and wait for network responses explicitly when an action triggers one. Traces from retried tests help you find the real cause.

Yes, Playwright has an experimental component testing mode that mounts components in a real browser. Many teams instead use Vitest with Testing Library for components and Playwright for full flows, or Vitest browser mode, which can use Playwright as its browser provider.

Fewer than unit and component tests. Cover the flows that would cost the most if they broke, like authentication, payments, and the main feature of your product, plus a smoke test that every important page loads. A small, reliable suite is worth more than a large, flaky one.

Create unique data per test, for example by adding a random suffix to emails or names, and clean it up afterwards through API calls. Better still, run end-to-end tests against a dedicated environment that's reset or seeded before each CI run.

Conclusion

Playwright makes end-to-end testing of React apps practical. Configure a webServer that serves your production build, write tests with role-based locators and retrying assertions, and let auto-waiting handle timing. Log in once with a setup project and storage state, use page.route for scenarios that are hard to reproduce against a real backend, and keep growing suites tidy with page objects and fixtures. Traces and UI mode turn failures from mysteries into quick fixes.

Start with one test for the most important flow in your app, the one that would trigger an incident if it broke. Get it running reliably in CI with traces enabled, then add a handful of other critical journeys. Combined with component tests in Vitest, that gives you confidence to ship changes quickly.

Tags :
Share :

Related Posts

A Practical Guide to useEffect and Its Dependency Array

A Practical Guide to useEffect and Its Dependency Array

useEffect is the hook people get wrong most often, and the dependency array is usually where it goes wrong. Leave a value out and your effect works

Continue Reading
Accessibility Best Practices for React Developers

Accessibility Best Practices for React Developers

React makes it easy to build interfaces out of anything. A div with an onClick looks and behaves like a button for a mouse user, so it ships. The

Continue Reading
Animations in React with Motion (Framer Motion)

Animations in React with Motion (Framer Motion)

CSS transitions get you far, until you need to animate something leaving the page. React removes the element from the DOM immediately, so there's not

Continue Reading