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.
10On this page
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 tests | End-to-end | |
|---|---|---|
| Runs | jsdom, in Node | Chromium/Firefox/WebKit |
| Server | Mocked | Real, or seeded |
| Catches | Logic, states, a11y of one unit | Routing, build config, auth, env, CSP, real network |
| Speed | Milliseconds | Seconds |
| How many | Hundreds | Ten 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
| Approach | Good for | Cost |
|---|---|---|
| Real backend, seeded database | Highest confidence; catches contract drift | Slow, needs cleanup and isolation between tests |
page.route() interception | Error paths, slow networks, states you cannot produce on demand | You are back to testing against your assumptions |
| Mock service worker in the app | Deterministic demo/CI runs | Never 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
- Sign up, sign in, sign out.
- The one flow that makes money — checkout, publish, submit.
- Anything with a payment or an irreversible side effect.
- A route that is server-rendered and one that is client-rendered.
- 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."
Opens in the editor: write it, run it, and check it against real tests.
All 66 for this level →Finished reading?
0 of 57 chapters read in this series