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.
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
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.
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:
// 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.
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
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.
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:
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:
# 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/4Then 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.
Quiz
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 / getByTextfirst. Auto-waiting kills most flakes. codegenwrites 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. SetbaseURLso one test runs against local, staging, prod. - Shard long suites across CI machines with
--shard=k/N.