End-to-end testing principles
September 14, 2026 · View on GitHub
These notes summarize how we approach Playwright tests in this codebase, based on the Epic Web E2E workshop and our existing setup.
Goals
- Validate user-visible journeys end-to-end through the worker and client.
- Prefer a few high-signal tests over many brittle ones.
- Keep tests readable and close to how a user describes behavior.
- Keep the bar for adding an E2E test very high.
What to test
- Only the most important happy-path user flows.
- Primary routes and flows that would make the product feel broken if they stopped working.
- Integration across the worker, client router, and API endpoints when that journey is central to the product.
Avoid testing implementation details, styling, or pure utility functions. Avoid adding E2E coverage for edge cases, low-probability regressions, or bug fixes that are unlikely to recur.
Bar for adding a test
- Default to not adding a new E2E test.
- Add one only when the flow is both user-critical and hard to cover with faster tests.
- Prefer a single broad happy-path journey over multiple narrow regression cases.
- If a bug is unlikely to show up again, do not add an E2E test just to lock in the fix.
- For MCP specifically, treat
*.mcp-e2e.test.tsas a tiny transport smoke suite. Do not add capability-by-capability coverage there unless the failure mode depends on the real MCP HTTP transport, OAuth flow, or package-app session wiring.
Structure and style
- Keep tests flat: top-level
test(...)with nodescribenesting. - Inline setup per test; avoid shared
beforeEachunless required. - Prefer fewer, longer tests when one user journey covers the behavior.
- Treat each E2E test like a manual tester's script: one setup, then the actions and assertions needed to validate the whole flow.
- Do not split a single journey into multiple tiny tests just to isolate each assertion.
- Use Playwright’s
expectand locator APIs (role/label/placeholder).
Locators
Prefer stable, user-facing selectors:
getByRolefor buttons, links, headings, and inputs.getByLabelfor form fields.getByTextonly for brief, stable copy.
Avoid page.locator('css') unless no accessible alternative exists.
Server and routing
- The test server is started via Playwright
webServerusing Vite. playwright.config.tsstarts the E2E server withnpm run e2e:web-server -- --port 3847(D1 migrations + the local Cloudflare API mock + Vite withCLOUDFLARE_ENV=test) and waits on/health. The test env keeps jobs and highlight as Vite auxiliary workers; platform/runtime auxiliary workers are skipped.tools/e2e-web-server.tsstartspackages/mock-servers/cloudflareand points the origin worker'sCLOUDFLARE_API_BASE_URL/CLOUDFLARE_API_TOKEN/CLOUDFLARE_ACCOUNT_IDat it so signup and other transactional mail go through the same Email Sending mocknpm run devuses. The mock Durable Object retains outbound messages. Specs read them from authenticatedGET /__mocks/messages(Bearer token or?token=) using the origin and token written to.wrangler/state/e2e/cloudflare-mock.json. Do not pull verification tokens out of D1 as the primary path; that skips the email leg.preview:e2eis the manual path: it preparespackages/worker/.env, applies local D1 migrations, and starts Vite against.wrangler/state/e2e.npm run test:e2e:runensures Playwright Chromium is installed before the suite starts (tools/ensure-playwright-browser.ts). The Validate E2E job restores~/.cache/ms-playwrightfrom Actions cache and calls the same ensure script, so a matching Playwright revision does not download or runapt-get.npm run test:e2e:uiand plainnpx playwright testassume Playwright browsers are already installed.- Playwright sets
CLOUDFLARE_ENV=test; Wrangler loadspackages/worker/.envvalues for local secrets. That test env is a single script: Durable Object classes run onkody-testwith noscript_name. Production andnpm run devattach origin, platform, runtime, jobs, and highlight as siblings. The test env still starts jobs and highlight as auxiliary workers. - Specs import
testfrome2e/playwright-utils.ts, which probes/healthbefore each test and fails fast withE2eWebServerDeadErrorif Wrangler has exited mid-suite (avoids burning retries onECONNREFUSED). That error names the unreadrequest.clone()tee fix (discardUnreadRequestBodyin#worker/request-body.ts) when logs showNetwork connection lost/Error inside ProxyWorker. Wrangler 4.131+ logs that ProxyWorker failure without exitingwrangler dev(workers-sdk#15252). Playwright also keeps wrangler's default incoming-body drain enabled so unused proxy tees do not kill the isolate.wrangler-env.tsand the Playwright webServer setX_LOCAL_EXPLORER=falsebecause wrangler 4.127+ starts Miniflare's local explorer by default; on Cloud Agent / CI hosts, explorer writes under.wrangler/tmpretrigger esbuild and leave ProxyWorker in a pause/reload loop after Ready. Opt in withX_LOCAL_EXPLORER=true.wrangler-env.tsalso setsWRANGLER_DISABLE_BUNDLE_WATCH=truein the test env so esbuild's source-graph watcher does not rebuild after the first compile on Cloud Agent overlay FS (Friction #1789). On CI, the🎭 E2Ejob uploadslogs.local/as thee2e-wrangler-logsartifact when the suite fails. - Ensure the
env.testsection inpackages/worker/wrangler.jsoncincludes assets, KV, and durable objects since these are not inherited from top-level Wrangler config. - Ensure
packages/worker/.envincludes aCOOKIE_SECRETvar for local sessions. - Client routes live in
packages/worker/client/app.tsxandpackages/worker/client/routes/index.tsx. - API endpoints are defined in
packages/worker/universal/routes.tsand mapped inpackages/worker/src/app/router.ts.
When adding endpoints that accept bodies, ensure POST/PUT requests are not
handled by the static asset fetcher in packages/worker/src/index.ts.
Test data
- Use real input values and a happy-path payload.
- Keep credentials and emails obviously fake and local-only.
- Avoid hidden fixtures or global state in the Playwright tests.
Assertions
- Assert user-facing results (success message, redirect, visible element).
- For async actions, wait on the UI result, not arbitrary timeouts.
- Assert important intermediate states as part of the same journey that causes them instead of creating isolated loading-state or transition-state tests.
- After
page.goto, wait for client hydration before clicking JS-only controls (waitForClientHydrationine2e/playwright-utils.ts). Boot preloads the route chunk before Remixrun(), so SSR headings are visible whileon('click')handlers are still unbound. - For client-router regressions, you may set a
windowmarker before clicking a link and assert it survives navigation to prove there was no full document reload. - Use the same marker pattern for form submissions (for example logout) when verifying router-handled form navigation.
Running tests
Common commands:
npm run test:e2e:runnpm run test:e2e:installnpm run test:e2e:run -- --grep "smoke test"npx playwright testnpx playwright test e2e/login.spec.ts
For MCP capability work, prefer *.node.test.ts or *.workers.test.ts beside
the implementation (see the
test flavor decision matrix)
and keep npm run test:mcp limited to a couple of high-signal smoke journeys.
If packages/worker/.env is missing, the E2E server startup path copies
packages/worker/.env.example to packages/worker/.env before Wrangler starts.
These tests are executed by the validate gate, alongside format:check,
lint, typecheck, unit tests, and the MCP E2E suite. validate is read-only;
use npm run validate:fix for format + lint:fix.