Skip to the chapter
←ReactGGroundwork⚛React
React/Intermediate/I18
29 / 57 written chapters
/

The cover
  1. B1Setup & JSX
  2. B2Thinking in React
  3. B3Components
  4. B4Props
  5. B5useState
  6. B6Events & conditionals
  7. B7Lists & keys
  8. B8Forms
  9. B9useEffect
  10. B10Lifting state & styling
  11. B11Guided project
  12. ★Cheat page
  1. I1useRef
  2. I2Context
  3. I3useReducer
  4. I4Custom hooks
  5. I5Effects in depth
  6. I6Routing
  7. I7State architecture
  8. I8Memoisation
  9. I9Boundaries & portals
  10. I10Data fetching
  11. I11Forms at scale
  12. I12State libraries
  13. I13Auth
  14. I14TanStack Query
  15. I15Testing
  16. I16TypeScript
  17. I17Animation
  18. I18E2E testing
  19. I19i18n
  20. I20File uploads
  21. I21Real-time UI
  22. I22shadcn/ui
  23. ★Cheat page
  1. A1Fiber
  2. A2Concurrent
  3. A3Suspense
  4. A4Server Components
  5. A5Server Actions
  6. A6use()
  7. A7Rendering strategies
  8. A8Effect timing
  9. A9External stores
  10. A10Refs in depth
  11. A11Component patterns
  12. A12Performance
  13. A13Accessibility
  14. A14Design systems
  15. A15Advanced TypeScript
  16. A16Architecture at scale
  17. A17Custom renderers
  18. A18Migrating & upgrading
  19. A19Production debugging
  20. A20Review & judgement
  21. A21State machines
  22. A22Feature flags
  23. A23Interview bank
  24. ★Cheat page

⚛I18 · Part 2 · Intermediate

End-to-end testing

The tests that run the real app in a real browser — and why you want few of them.

4 min read10 sections1 exercise
29 of 57

In this chapter

  • What this layer is for
  • Playwright, from zero
  • A first test
  • Waiting: the entire skill
  • Auth, done once

and 5 more

about 4 min
10On this page
  1. What this layer is for
  2. Playwright, from zero
  3. A first test
  4. Waiting: the entire skill
  5. Auth, done once
  6. What to do about the backend
  7. Debugging a failure you cannot reproduce
  8. Two more things this layer gets you cheaply
  9. What to actually cover
  10. Cypress, briefly

What this layer is for

Testing components covers unit and integration tests: your component, a fake server, jsdom. Those never prove the app works. They mount a piece of it, with a router you configured and data you wrote. End-to-end tests start a real build, open a real browser, and click.

Component testsEnd-to-end
Runsjsdom, in NodeChromium/Firefox/WebKit
ServerMockedReal, or seeded
CatchesLogic, states, a11y of one unitRouting, build config, auth, env, CSP, real network
SpeedMillisecondsSeconds
How manyHundredsTen to thirty

The count matters. E2E tests are the ones that fail on Tuesday for no reason and get skipped by Friday. Keep the set small enough that a red run is always worth reading.

Playwright, from zero

npm init playwright@latest
// playwright.config.ts
export default defineConfig({
  testDir: "./e2e",
  use: { baseURL: "http://localhost:3000", trace: "on-first-retry" },
  webServer: {
    command: "npm run build && npm run start",   // production build, not dev
    url: "http://localhost:3000",
    reuseExistingServer: !process.env.CI,
  },
  retries: process.env.CI ? 2 : 0,
});

Testing the dev server is testing something you never ship — different bundling, no minification, React in development mode, no static optimisation. Half the bugs this layer exists to catch only appear in the production build.

A first test

import { test, expect } from "@playwright/test";

test("a visitor can add a task", async ({ page }) => {
  await page.goto("/board");

  await page.getByLabel("New task").fill("Ship the release");
  await page.getByRole("button", { name: "Add" }).click();

  await expect(page.getByRole("listitem").filter({ hasText: "Ship the release" })).toBeVisible();
});

Same selector philosophy as Testing Library, for the same reason: query by role, label and text, so the test breaks when the user experience breaks and not when a class name changes. When there is genuinely nothing user-visible to grab, use data-testid — deliberately, not by default.

Waiting: the entire skill

// ✗ flaky — every one of these is a guess
await page.waitForTimeout(1000);
expect(await page.locator(".row").count()).toBe(3);

// ✓ web-first assertions retry until they pass or time out
await expect(page.getByRole("row")).toHaveCount(3);
await expect(page.getByText("Saved")).toBeVisible();
The rule that removes most flake

A locator is a query, not a result — it is re-run on every retry. So expect(locator) waits; await locator.count() reads once and hopes. Anything with waitForTimeout in it is a test you will delete in six months.

Actions auto-wait too: click() waits for the element to be attached, visible, stable, enabled and unobscured before it clicks. Most "element not found" failures are real bugs about timing, not Playwright being slow.

Auth, done once

// e2e/auth.setup.ts
setup("authenticate", async ({ page }) => {
  await page.goto("/login");
  await page.getByLabel("Email").fill(process.env.E2E_USER);
  await page.getByLabel("Password").fill(process.env.E2E_PASS);
  await page.getByRole("button", { name: "Sign in" }).click();
  await expect(page.getByText("Dashboard")).toBeVisible();
  await page.context().storageState({ path: "e2e/.auth/user.json" });
});
projects: [
  { name: "setup", testMatch: /auth\.setup\.ts/ },
  { name: "chromium", use: { storageState: "e2e/.auth/user.json" }, dependencies: ["setup"] },
]

Log in once, save the cookies and storage, reuse across every test. Logging in at the top of thirty tests adds minutes to the run and gives you thirty places to break. Keep one test that actually exercises the login form.

What to do about the backend

ApproachGood forCost
Real backend, seeded databaseHighest confidence; catches contract driftSlow, needs cleanup and isolation between tests
page.route() interceptionError paths, slow networks, states you cannot produce on demandYou are back to testing against your assumptions
Mock service worker in the appDeterministic demo/CI runsNever exercises the real API
// the thing interception is genuinely best at
await page.route("**/api/tasks", (route) => route.fulfill({ status: 500 }));
await page.goto("/board");
await expect(page.getByRole("alert")).toContainText("Could not load");

A sensible split: the happy paths run against a real seeded backend, the failure paths are intercepted. Do not try to seed a 500.

Debugging a failure you cannot reproduce

npx playwright test --ui          # time-travel runner, watch mode
npx playwright test --debug       # step through with the inspector
npx playwright show-trace trace.zip   # the CI failure, frame by frame

trace: "on-first-retry" is the setting that pays for itself: when CI fails, you download an artifact containing every DOM snapshot, network call and console message, and scrub through it. Without traces, a flaky CI-only failure can cost a whole day.

Two more things this layer gets you cheaply

// visual regression
await expect(page).toHaveScreenshot("board.png", { maxDiffPixels: 100 });

// accessibility, on real rendered pages
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);

Screenshots are powerful and high-maintenance — fonts, scrollbars and animation make them differ across machines. If you use them, pin the browser version, disable animation, and generate the baselines in CI's own container, never on your laptop.

What to actually cover

  1. Sign up, sign in, sign out.
  2. The one flow that makes money — checkout, publish, submit.
  3. Anything with a payment or an irreversible side effect.
  4. A route that is server-rendered and one that is client-rendered.
  5. One deep link straight into an authenticated page, cold.

That is often eight tests. Everything else — validation messages, empty states, sorting, edge cases — belongs one layer down, where it runs in milliseconds and never flakes.

Cypress, briefly

You will meet it in existing codebases. Same idea, different shape: it runs inside the browser (so it has always had a great watch UI), its commands are chained rather than awaited, and cross-browser and multi-tab support came later and is weaker. For a new project in 2026, Playwright is the default — parallelism, real cross-browser, and the trace viewer. Do not rewrite a working Cypress suite to prove a point.

Interview line

"Lots of component tests, a handful of E2E. E2E runs the production build in a real browser, so it catches the things jsdom cannot — routing, auth, env, CSP. I keep the set to the flows the business cares about, log in once with a saved storage state, use web-first assertions instead of waits, and turn on traces so a CI-only failure is debuggable."

Interview answer, one sentence

"End-to-end tests cover a few critical user journeys in a real browser; the skill is web-first waiting, with assertions that retry instead of fixed sleeps, authenticating once and reusing the state, and leaving everything a lower layer can check to unit and component tests."

01Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 8 testsPassed, flaky or failed?intermediate
All 66 for this level →
I18

Finished reading?

0 of 57 chapters read in this series

→Next stop

  1. I17Previous · I17Animation and transitions
  2. I18you are here
  3. Next · I19InternationalisationTranslating strings is the easy third. Plurals, formats and layout direction are the rest.5 min read

01On this page

10 sections · 0%

  1. 01What this layer is for
  2. 02Playwright, from zero
  3. 03A first test
  4. 04Waiting: the entire skill
  5. 05Auth, done once
  6. 06What to do about the backend
  7. 07Debugging a failure you cannot reproduce
  8. 08Two more things this layer gets you cheaply
  9. 09What to actually cover
  10. 10Cypress, briefly

[] chapters · t top