playwright-fixture-model

The Playwright Fixture Model

YouTeacher's end-to-end suite runs against real services in Docker, not mocks of its own backend. The custom fixture layer (tests/e2e/fixtures.ts) is what makes that survivable: it decides how browsers are shared, how each role signs in, and — importantly — what counts as a test failure even when every assertion passes.

Worker-scoped context, per-test page

Creating a fresh BrowserContext is expensive (the fixture comment measures roughly 500ms each), so the suite creates one context per worker and reuses it across every test that worker runs. This lives in a worker-scoped sharedContext fixture, and Playwright's built-in context fixture is overridden to hand back that shared one. Each test still gets its own fresh page.

Because the context persists across tests in a worker, its storage does too. The page fixture defends against state bleed: at setup it clears sessionStorage and localStorage (dropping OTP cooldown timers and passkey hints), and at teardown it signs the user out through the real UI if a session is present, the way a real user would leave.

Isolated per-role pages

Four role fixtures exist — adminPage, employerPage, talentPage, recruiterPage — plus a generic authenticatedPage and a jobsPage entry point. Each seeds a unique user through the real signup UI, keyed by worker index and a timestamp so emails never collide across parallel runs or reruns.

The sharing rule splits on a real constraint: a test that needs two roles at once (talent and employer in the same scenario) cannot let them share cookies. So employerPage, talentPage, and recruiterPage each build their own isolated BrowserContext rather than the worker-shared one — cookies and storage stay separate per role. The single-role adminPage reuses the shared context. Admin seeding leans on the test auth service assigning the admin type at signup, so no separate elevation step runs in the fixture.

The "goto only in fixtures" rule

A stated invariant runs through the file: page.goto() is allowed only in fixtures, never in test bodies. Tests must reach every other screen by clicking and scrolling, like a real user. The initial entry points (/en/jobs, the signup page) are navigated inside the fixtures, each marked with an eslint exemption comment that names the reason. The intent is that tests exercise real navigation paths instead of teleporting to URLs the product would never link to directly.

Navigation itself is hardened. The fixture patches page.goto and page.waitForURL to wait for domcontentloaded instead of the default load event: under E2E, third-party resources (analytics, web fonts, a newsletter CDN) can hang forever, and their pending requests would otherwise block load and time the navigation out. It also retries a navigation once on transient Chromium network errors (network-changed, connection-reset, aborted) that show up under heavy parallel Docker load — treated as environmental, not app bugs.

Console errors as a failure gate

The most opinionated part: a test that makes no failed assertion can still fail at teardown if the page logged a console error or threw an uncaught page error. The page fixture collects every console.error/console.warn and every pageerror, and throws at the end if the list is non-empty, attaching the collected errors to the report. It also scans for a Next.js error overlay and fails if one is present. The idea is to catch real bugs that never surface as a broken assertion.

That gate is only as good as its noise filter. consoleFilter.ts holds a central IGNORED_PATTERNS list of expected noise — failed auth probes returning 401/403/404, FedCM warnings on insecure origins, source-map notices, rate-limit messages, aborted in-flight fetches during navigation, flaky external CDNs. The file carries a firm rule in its own comment: do not modify this list without explicit permission, because every pattern added here can mask a real bug. The per-test escape hatch is allowConsoleError(pattern), which whitelists one pattern for the current test only and is cleared automatically before the next.

The React 19 Float #418 guard

A dedicated guard (hydration418Guard.ts) exists to suppress a specific false positive, not to silence hydration errors in general. Next.js 15.5+ with React 19.2+ can throw hydration error #418 when React's Float system relocates async <script> tags out of <head> during the hydration commit, confusing the hydration cursor. The <body> stays identical; only <head> differs. This is an upstream bug, not application code.

The guard installs a browser-side hook before navigation. It snapshots the DOM at DOMContentLoaded, and when a #418 error is constructed it synchronously diffs that snapshot against the live DOM. If the body is identical (a head-only mismatch — or a known nav-only hydration difference), it rewrites the error to a benign marker and sets a window flag; a post-test filter then removes the false-positive #418 from the collected error list. If the body has genuine differences, the original error propagates normally and the test fails as it should. The file notes this whole workaround should be removed once the upstream bug is fixed.

Config baseline

playwright.config.ts sets the surrounding envelope: a 90-second per-test timeout (real services are slow) with a 15-second per-assertion timeout, fully-parallel execution with four workers when headless (sequential and slowed-down when headed for visibility), one retry, and Chromium only. Traces and screenshots are retained only on failure. Docker launch args disable the GPU and route Chromium's shared memory to /tmp, since Docker's default /dev/shm is too small.

Inside this node

  • The fixture layer is the seam between "real services in Docker" and "tests that read like user journeys."
  • Two axes of sharing: context (worker-shared vs. per-role isolated) and page (always per-test, always cleaned).
  • Two failure gates beyond assertions: console errors and the Next.js overlay — with one narrow, well-documented suppression for an upstream hydration bug.
about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →