webdev.complete
๐Ÿงช Testing
๐ŸšฆGoing to Production
Lesson 103 of 117
25 min

Playwright E2E

Browser automation. Traces. Codegen.

Unit tests check that a function returns the right number. E2E tests check that a human can actually buy a thing on your site. They launch a real browser, click real buttons, follow real redirects, fail when the login flow is broken at 3am on a Saturday. Playwright is the tool most teams have settled on for this, and it's genuinely a joy to use once you learn its handful of idioms.

What Playwright actually is

It's a Node library plus CLI that drives Chromium, Firefox, and WebKit. The same test runs across all three (and on mobile viewports, and in dark mode, and with a slow 3G simulation if you want). It comes with its own test runner, so you don't need Jest or Vitest to use it.

bash
npm init playwright@latest
# Asks: TypeScript or JS? Where do tests go? Should it install browsers?
# Say yes to everything. It writes a playwright.config.ts and example tests.

Your first test

tests/example.spec.ts
import { test, expect } from "@playwright/test";

test("homepage shows the marketing headline", async ({ page }) => {
  await page.goto("https://example.com");
  await expect(page).toHaveTitle(/Example Domain/);
  await expect(page.getByRole("heading", { level: 1 })).toContainText(
    "Example Domain",
  );
});

Run it with npx playwright test. By default it launches all three browsers headlessly in parallel and reports pass/fail. Pass --headed to watch in a real window, --debug to step through with a paused inspector.

The killer feature: codegen
Run npx playwright codegen https://yoursite.com. A browser opens. Click around. Playwright writes the test for you, complete with the right locators. Real teams use this as a starting point and then edit. Don't hand-write locators from scratch when you don't have to.

Locators: how Playwright finds elements

Just like React Testing Library, Playwright pushes you toward accessible queries. The page.locator() family:

ts
// Preferred - accessible, resilient
page.getByRole("button", { name: "Sign in" });
page.getByLabel("Email address");
page.getByPlaceholder("you@example.com");
page.getByText("Welcome back");
page.getByTitle("Settings");

// Last resort - brittle
page.locator("#submit-btn");
page.locator(".css-xyz123");

Locators are lazy and auto-retrying. That second bit is what makes Playwright pleasant. When you write await page.getByRole("button").click(), Playwright doesn't fail immediately if the button isn't there. It keeps polling for up to the configured timeout (5 seconds by default), waiting for the element to exist, be visible, be enabled, and not animate. So page.waitForTimeout(2000) is practically never needed.

Don't sleep, wait for state
If a test is flaky, the fix is almost never waitForTimeout. It's asserting on the thing you actually care about: await expect(page.getByText("Saved")).toBeVisible(). Playwright will wait for the text to appear, then continue.

A realistic flow: login then check dashboard

tests/login.spec.ts
import { test, expect } from "@playwright/test";

test("user logs in and sees their dashboard", async ({ page }) => {
  await page.goto("/login");

  await page.getByLabel("Email").fill("ada@example.com");
  await page.getByLabel("Password").fill("hunter2");
  await page.getByRole("button", { name: "Sign in" }).click();

  // Wait for navigation + assert we landed on the right page
  await expect(page).toHaveURL("/dashboard");
  await expect(
    page.getByRole("heading", { name: /welcome, ada/i }),
  ).toBeVisible();

  // The greeting card should load user data
  await expect(page.getByTestId("user-email")).toHaveText("ada@example.com");
});

Notice the relative URL /login. Set baseURL in playwright.config.ts so the same test works against localhost, staging, and a preview deploy.

Auto-waiting in action

Playwright's actions (click, fill, check) have built-in actionability checks. Before clicking a button, Playwright waits for it to be:

  • Attached to the DOM.
  • Visible (no display: none, non-zero size).
  • Stable (not animating).
  • Receives events (not covered by another element).
  • Enabled.

All of those are checked on every poll. That's why you almost never need explicit waits.

Traces: the debugging superpower

When a test fails, you want to know exactly what the page looked like at the moment of failure. Traces capture a full timeline: screenshots, DOM snapshots, network calls, console logs, and the actions Playwright took.

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

export default defineConfig({
  use: {
    baseURL: process.env.BASE_URL ?? "http://localhost:3000",
    trace: "on-first-retry", // capture trace when a retry happens
    screenshot: "only-on-failure",
    video: "retain-on-failure",
  },
  retries: process.env.CI ? 2 : 0,
  projects: [
    { name: "chromium", use: { browserName: "chromium" } },
    { name: "firefox", use: { browserName: "firefox" } },
    { name: "webkit", use: { browserName: "webkit" } },
  ],
});

After a failure, open the trace with npx playwright show-trace trace.zip. You get a time-travel debugger: scrub the timeline to see exactly what the page looked like before, during, and after the failing step. This is the single best tool for diagnosing flaky tests in CI.

Network mocking

Sometimes you want to test the UI's response to a particular API failure (a 500, a slow response, an empty list). Intercept network calls:

ts
test("shows an error when the server is down", async ({ page }) => {
  await page.route("**/api/users", (route) =>
    route.fulfill({ status: 500, body: "boom" }),
  );

  await page.goto("/users");
  await expect(page.getByText(/something went wrong/i)).toBeVisible();
});

Sharding in CI

A growing test suite eventually takes ten minutes locally and twenty in CI. Split it across multiple machines:

bash
# In a matrix CI job, each shard runs ~1/N of the tests in parallel
npx playwright test --shard=1/4
npx playwright test --shard=2/4
npx playwright test --shard=3/4
npx playwright test --shard=4/4

Then a final job stitches the HTML reports together with npx playwright merge-reports. Most CI providers (GitHub Actions, CircleCI, Vercel CI) document a Playwright sharding template.

Playwright vs Cypress?
Both are good. Playwright pulls ahead on: real cross-browser support (Cypress only does Chromium-family well), parallelism out of the box, and the trace viewer. Cypress wins on: the "time-travel" in-browser debugger UI for local dev. Most new projects in 2024+ pick Playwright. If you're already on Cypress, no need to migrate.

Quiz

Quiz1 / 3

What does Playwright do when you call .click() on a locator that doesn't exist yet?

Recap

  • Playwright drives Chromium, Firefox, and WebKit from one test, with its own runner.
  • Use page.getByRole / getByLabel / getByText first. Auto-waiting kills most flakes.
  • codegen writes the first draft of any test for you.
  • Configure trace: "on-first-retry" and you get a time-travel debugger for CI failures.
  • Mock the network with page.route. Set baseURL so one test runs against local, staging, prod.
  • Shard long suites across CI machines with --shard=k/N.