Part XIV — End-to-end testing in depth with Playwright
This Part tests one real application end to end: Support Desk (§63),
which has sign-in, sessions, streaming agent replies, actionable UI cards,
security headers and rate limits. The suite has 26 tests across three
Playwright projects (setup, desktop Chromium, mobile). It runs in about 3
seconds, and it passed 251 out of 251 runs with --repeat-each=10. On
the way there it found six real problems, some in the app and some in the
tests. Those problems are the most useful part of this Part, and §78 tells
each story.
74. A testing strategy for web apps, and how Playwright works
What E2E tests are for
| Layer | Tool | Tests | Speed |
|---|---|---|---|
| Unit | Vitest / node --test |
pure functions: the event-loop model (§81), prop validation, reducers | ms |
| Component | Vitest Browser Mode, Playwright CT | one component in a real browser | ~100 ms |
| API | Playwright request, supertest |
status codes, headers, auth rules, without a page | ~10 ms |
| End-to-end | Playwright | critical user journeys through the real UI, server and browser | ~1 s |
E2E tests are the most expensive kind, so spend them on what only they can
prove: that sign-in works, that the streaming reply renders, that a refund
needs approval, that a cookie is HttpOnly. Pure logic belongs in unit
tests. Support Desk's suite mixes UI tests with API tests in the same runner,
because some security properties (an IDOR, a missing CSRF token) are API
facts and are cheaper to check without a page.
Why Playwright
Playwright is the default choice for new projects in 2026: it is free, it covers all three engines (Chromium, Firefox and WebKit, so Safari behavior is testable on Linux CI), and it auto-waits, which removes most sleeps and flakiness. Cypress is still loved for its in-browser debugging, but it runs inside the page, which limits multi-tab, multi-origin and multi-user tests. WebdriverIO and Selenium speak the W3C WebDriver (and now WebDriver BiDi) protocol, and they make sense when you need real mobile devices or a vendor grid.
How it works under the hood
┌───────────────── Node.js ─────────────────┐ ┌──────────── browser process ────────────┐
│ test runner ──spawns──► worker 1, 2, … N │ WebSocket │ Chromium: CDP │
│ each worker: playwright client library │ ─── or ───►│ Firefox: Juggler (patched protocol) │
│ test → fixture → page.click(...) │ pipe │ WebKit: Playwright protocol (patched) │
└───────────────────────────────────────────┘ │ ├─ BrowserContext A (isolated profile)│
│ │ └─ Page(s) │
│ └─ BrowserContext B … │
└─────────────────────────────────────────┘
- Out of process. Tests run in Node and drive the browser over a protocol. That makes them independent of the page's own event loop (§12), and lets one test control several tabs, origins and users.
- Browser contexts are incognito-like profiles with their own cookies, storage and cache. Creating one takes milliseconds, so every test gets a fresh one, and that is the basis of test isolation. Two contexts in one test means two users (§77).
- Workers are separate OS processes running test files in parallel.
fullyParallel: truealso parallelizes the tests inside a file. - Locators are lazy.
page.getByRole("button", { name: "Send" })doesn't look anything up when you create it. Each action resolves it again, so it survives re-renders. - Actionability checks. Before
click(), Playwright waits until the element is visible, stable (not animating), receives events (not covered by an overlay) and enabled.fill()also waits for it to be editable. This is why Playwright tests rarely needwaitForTimeout. - Web-first assertions like
await expect(locator).toHaveText(...)retry until they pass or time out (5 s by default). A one-off read likeexpect(await locator.textContent()).toBe(...)does not retry. It is the most common cause of flaky Playwright tests.