End-to-End Testing with Playwright
Purpose
Write browser tests that fail only when the application is broken. An end-to-end suite that fails randomly is worse than no suite: it consumes attention and trains the team to ignore red.
When to Use
- Testing a complete user journey through a real browser.
- Replacing a flaky Selenium or Cypress suite.
- Testing flows that cross pages, tabs, or authentication.
- Debugging an intermittent E2E failure.
Capabilities
- Resilient locators based on user-visible attributes.
- Auto-waiting and web-first assertions.
- Network interception, mocking, and request assertion.
- Authentication state reuse across tests.
- Parallel execution and sharding.
- Trace, video, and screenshot capture on failure.
Inputs
- The journeys that matter enough to test end to end (there are fewer than you think).
- The application, its authentication, and its test data strategy.
Outputs
- Tests locating elements by role and accessible name.
- Zero explicit waits or sleeps.
- Traces on failure that make a CI-only failure diagnosable.
Workflow
- Choose the journeys — End-to-end tests are slow and expensive. Test the paths whose failure would be a serious incident: sign up, check out, pay. Not every form.
- Locate the way a user would —
getByRole("button", { name: "Place order" }). Not a CSS class, not an XPath. Class names change; the accessible name is the contract with the user.
- Never sleep — Playwright's assertions auto-wait and retry.
waitForTimeout is the single largest cause of both flakiness and slowness in an E2E suite.
- Reuse authentication — Log in once in a setup project, save the storage state, and reuse it. Logging in before every test triples the suite runtime.
- Control the network where the test is not about the network — Mock the third-party payment provider; do not depend on its sandbox being up.
- Capture traces on failure — A CI failure with a trace is diagnosable in two minutes. Without one, it is a mystery.
Best Practices
- Any
waitForTimeout in a Playwright test is a bug. If you need to wait for something, assert on the thing you are waiting for — the assertion retries until it is true.
- Locating by CSS class couples the test to the styling. A CSS refactor should not break the test suite.
- Tests must be independent and able to run in any order, in parallel. A test that depends on a previous test's data will fail intermittently forever.
- Each test creates the data it needs, with a unique identifier. Shared fixture data plus parallel execution equals a race condition.
- Assert on the user-visible outcome, not on an implementation detail. "The order confirmation shows the order number" — not "the POST returned 201".
- Run the suite against a production-like build. E2E tests against a dev server with hot reloading are testing something you do not ship.
Examples
Resilient, independent, and free of sleeps:
import { test, expect } from "@playwright/test";
test.describe("checkout", () => {
test("a customer can place an order and see it confirmed", async ({ page }) => {
// Unique data per test: the suite can run in parallel without collisions.
const email = `test-${crypto.randomUUID()}@example.com`;
await seedCustomer({ email, cardOnFile: true });
// The payment provider is a third party. The test is not about their uptime.
await page.route("**/v1/payment_intents", (route) =>
route.fulfill({ status: 200, json: { id: "pi_test", status: "succeeded" } }),
);
await page.goto("/checkout");
// Located as a user perceives them, not by class name.
await page.getByRole("textbox", { name: "Email" }).fill(email);
await page.getByRole("button", { name: "Place order" }).click();
// Web-first assertion: retries until it passes or times out. No sleep needed.
await expect(page.getByRole("heading", { name: "Order confirmed" })).toBeVisible();
await expect(page.getByTestId("order-number")).toHaveText(/^ord_[0-9A-Z]{10}$/);
});
});
Authentication reused, not repeated:
// auth.setup.ts — runs once, before everything.
setup("authenticate", async ({ page }) => {
await page.goto("/login");
await page.getByRole("textbox", { name: "Email" }).fill(process.env.TEST_USER!);
await page.getByRole("textbox", { name: "Password" }).fill(process.env.TEST_PASS!);
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
await page.context().storageState({ path: ".auth/user.json" });
});
// playwright.config.ts
projects: [
{ name: "setup", testMatch: /auth\.setup\.ts/ },
{
name: "chromium",
dependencies: ["setup"],
use: { storageState: ".auth/user.json" }, // every test starts logged in
},
],
Notes
trace: "on-first-retry" in the config gives you a full trace — DOM snapshots, network, console — for exactly the runs that failed, at negligible cost. It is the difference between debugging a CI flake in minutes and never diagnosing it.
- Playwright's auto-waiting covers actionability (visible, enabled, stable, receives events). It does not cover your application's asynchronous state — for that, assert on what the user would see.
- Component testing (Playwright, Vitest browser mode) covers far more ground per second than end-to-end testing. Push tests down the pyramid whenever the coverage is equivalent.
1---2name: e2e-playwright3description: Use when writing end-to-end browser tests with Playwright. Covers resilient locators, auto-waiting, network interception, authentication reuse, parallelization, and eliminating flakiness.4---56# End-to-End Testing with Playwright78## Purpose910Write browser tests that fail only when the application is broken. An end-to-end suite that fails randomly is worse than no suite: it consumes attention and trains the team to ignore red.1112## When to Use1314- Testing a complete user journey through a real browser.15- Replacing a flaky Selenium or Cypress suite.16- Testing flows that cross pages, tabs, or authentication.17- Debugging an intermittent E2E failure.1819## Capabilities2021- Resilient locators based on user-visible attributes.22- Auto-waiting and web-first assertions.23- Network interception, mocking, and request assertion.24- Authentication state reuse across tests.25- Parallel execution and sharding.26- Trace, video, and screenshot capture on failure.2728## Inputs2930- The journeys that matter enough to test end to end (there are fewer than you think).31- The application, its authentication, and its test data strategy.3233## Outputs3435- Tests locating elements by role and accessible name.36- Zero explicit waits or sleeps.37- Traces on failure that make a CI-only failure diagnosable.3839## Workflow40411. **Choose the journeys** — End-to-end tests are slow and expensive. Test the paths whose failure would be a serious incident: sign up, check out, pay. Not every form.422. **Locate the way a user would** — `getByRole("button", { name: "Place order" })`. Not a CSS class, not an XPath. Class names change; the accessible name is the contract with the user.433. **Never sleep** — Playwright's assertions auto-wait and retry. `waitForTimeout` is the single largest cause of both flakiness and slowness in an E2E suite.444. **Reuse authentication** — Log in once in a setup project, save the storage state, and reuse it. Logging in before every test triples the suite runtime.455. **Control the network where the test is not about the network** — Mock the third-party payment provider; do not depend on its sandbox being up.466. **Capture traces on failure** — A CI failure with a trace is diagnosable in two minutes. Without one, it is a mystery.4748## Best Practices4950- Any `waitForTimeout` in a Playwright test is a bug. If you need to wait for something, assert on the thing you are waiting for — the assertion retries until it is true.51- Locating by CSS class couples the test to the styling. A CSS refactor should not break the test suite.52- Tests must be independent and able to run in any order, in parallel. A test that depends on a previous test's data will fail intermittently forever.53- Each test creates the data it needs, with a unique identifier. Shared fixture data plus parallel execution equals a race condition.54- Assert on the user-visible outcome, not on an implementation detail. "The order confirmation shows the order number" — not "the POST returned 201".55- Run the suite against a production-like build. E2E tests against a dev server with hot reloading are testing something you do not ship.5657## Examples5859**Resilient, independent, and free of sleeps:**6061```typescript62import { test, expect } from "@playwright/test";6364test.describe("checkout", () => {65 test("a customer can place an order and see it confirmed", async ({ page }) => {66 // Unique data per test: the suite can run in parallel without collisions.67 const email = `test-${crypto.randomUUID()}@example.com`;68 await seedCustomer({ email, cardOnFile: true });6970 // The payment provider is a third party. The test is not about their uptime.71 await page.route("**/v1/payment_intents", (route) =>72 route.fulfill({ status: 200, json: { id: "pi_test", status: "succeeded" } }),73 );7475 await page.goto("/checkout");7677 // Located as a user perceives them, not by class name.78 await page.getByRole("textbox", { name: "Email" }).fill(email);79 await page.getByRole("button", { name: "Place order" }).click();8081 // Web-first assertion: retries until it passes or times out. No sleep needed.82 await expect(page.getByRole("heading", { name: "Order confirmed" })).toBeVisible();83 await expect(page.getByTestId("order-number")).toHaveText(/^ord_[0-9A-Z]{10}$/);84 });85});86```8788**Authentication reused, not repeated:**8990```typescript91// auth.setup.ts — runs once, before everything.92setup("authenticate", async ({ page }) => {93 await page.goto("/login");94 await page.getByRole("textbox", { name: "Email" }).fill(process.env.TEST_USER!);95 await page.getByRole("textbox", { name: "Password" }).fill(process.env.TEST_PASS!);96 await page.getByRole("button", { name: "Sign in" }).click();97 await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();9899 await page.context().storageState({ path: ".auth/user.json" });100});101102// playwright.config.ts103projects: [104 { name: "setup", testMatch: /auth\.setup\.ts/ },105 {106 name: "chromium",107 dependencies: ["setup"],108 use: { storageState: ".auth/user.json" }, // every test starts logged in109 },110],111```112113## Notes114115- `trace: "on-first-retry"` in the config gives you a full trace — DOM snapshots, network, console — for exactly the runs that failed, at negligible cost. It is the difference between debugging a CI flake in minutes and never diagnosing it.116- Playwright's auto-waiting covers actionability (visible, enabled, stable, receives events). It does not cover *your* application's asynchronous state — for that, assert on what the user would see.117- Component testing (Playwright, Vitest browser mode) covers far more ground per second than end-to-end testing. Push tests down the pyramid whenever the coverage is equivalent.