Production reality: the things that bite
A handful of passing tests on your laptop is one thing. A suite that runs on every pull request, stays fast, and doesn't cry wolf is another. This phase is the gap between the two: how to skip logging in on every test, how parallelism really works, how to stop depending on a flaky backend, and the traps that catch almost everyone once.
Don't log in a hundred times — reuse auth state
If every test starts by filling the login form, your suite is slow and the login flow is tested a hundred redundant times. The fix: log in once, save the browser's storage (cookies and localStorage) to a file, and load it into every test. Playwright calls this storage state.
// auth.setup.ts — runs once before the tests that need a logged-in user
;
const authFile = 'playwright/.auth/user.json';
;
What just happened: a setup test logged in like a normal user, then wrote the resulting cookies and localStorage to user.json. Every test that loads this file starts already authenticated — the login form runs once, not once per test.
Wire it up in the config so real tests depend on it and inherit the saved state:
// playwright.config.ts
;
What just happened: the chromium project depends on setup, so Playwright runs the login once first, then runs every chromium test with the saved storage state already loaded. (Add playwright/.auth/ to .gitignore — those files hold session credentials.)
Fixtures: the page you've been getting for free
Every test so far destructured { page }. That page is a fixture — a piece of test environment Playwright builds for you, fresh per test, and tears down afterward. Built-in fixtures include page, context (an isolated browser session), and browser. The point of per-test fixtures is isolation: each test gets a clean context with no leftover cookies or state from the last one, which is a huge source of cross-test flakiness eliminated by default.
You can define your own to remove repeated setup:
// Provide an already-on-the-dashboard page to any test that asks for it
;
;
What just happened: dashboardPage is a custom fixture. A test that asks for { dashboardPage } receives a page already navigated to the dashboard, and the setup lives in one place instead of being copy-pasted into every test.
Parallelism and isolation
By default Playwright runs test files in parallel across multiple worker processes, and each test gets its own isolated browser context. Fast, but it has consequences you need to respect:
- Tests must not depend on each other or on order. A worker may run any file at any time. Shared state between tests is a bug waiting to surface.
- Watch for shared backend data. If two parallel tests both create a user named
[email protected], they collide. Generate unique data per test (a timestamp or random suffix) or scope to per-worker data.
You control the degree of parallelism when you need to:
# Limit workers (e.g. a resource-constrained CI box)
# Force fully serial for one stubborn file (last resort)
# test.describe.configure({ mode: 'serial' }) inside the file
What just happened: --workers=2 capped parallelism for the whole run. mode: 'serial' is the in-file escape hatch when tests genuinely must share order — use it sparingly, because serial mode also means one failure skips the rest of that group.
Stop depending on a flaky backend: mock the network
E2E tests that hit a real API inherit that API's flakiness and slowness. When you're testing the front end's behavior — does it render this data, does it handle this error — intercept the request and return a fixed response. This makes the test fast, deterministic, and able to exercise error states you can't easily trigger for real.
;
What just happened: page.route(...) caught the request to /api/orders and answered with an empty array — no real backend involved. The test then asserted the empty-state message, deterministically, every run.
Mock for front-end behavior tests; keep a few unmocked, full-stack "smoke" tests that hit the real system end to end. The mocked tests give you speed and coverage of edge cases; the smoke tests prove the pieces actually connect. You want both, not one or the other.
Cross-browser, for real
Phase 1 promised cross-browser. Here's the cost-benefit. Add the engines as projects:
// playwright.config.ts
projects: ,
What just happened: every test now runs three times, once per engine, catching the Safari-only and Firefox-only bugs that Chromium-only suites miss. The tradeoff is roughly triple the runtime — many teams run all three on the main branch and a single engine on routine pull requests.
CI and the classic traps
Running in CI is a config detail and a few hard-won lessons:
# In CI: install browsers with their OS dependencies
# Run; CI mode enables retries/reporters per your config
What just happened: --with-deps installed the system libraries the browsers need on a bare CI image — the single most common reason a suite that works locally explodes on first CI run.
The traps that catch nearly everyone at least once:
- Real waits sneaking back in.
await page.waitForTimeout(2000)is the newsleep— it's in the API for rare cases, but if it's in normal tests you've reintroduced flakiness. Use web-first assertions instead. See /guides/flaky-tests for the full pattern. - Asserting on a value instead of a locator. Re-read phase 2's
toHaveTextvstextContentexample; this is the number-one source of "passes locally, fails in CI." - Order-dependent tests. They pass serially on your machine and fail under parallel workers. Make each test self-contained.
- Committing auth/storage-state files. They contain live session tokens. Gitignore them and regenerate in CI.
- Strict-mode violations. If a locator matches more than one element, Playwright errors on purpose rather than silently picking the first. That error is a feature — narrow the locator (scope it, or use an accessible name) instead of suppressing it.
For builders: a healthy suite is mostly mocked behavior tests for speed, a thin layer of real-backend smoke tests for confidence, trace: 'on-first-retry' so failures are diagnosable, and storage-state auth so it stays fast. Get those four right and your E2E tests become something the team trusts instead of mutes.
[
{
"q": "Why save and reuse storage state across tests?",
"choices": [
"To run tests in parallel",
"To log in once and start every test already authenticated, instead of running the login flow in every test",
"To record a trace of the login",
"To mock the network"
],
"answer": 1,
"explain": "Storage state persists cookies and localStorage so tests load an authenticated session rather than re-running the slow login each time."
},
{
"q": "What is the main risk introduced by running tests in parallel?",
"choices": [
"Tests run too slowly",
"Traces stop being recorded",
"Tests that depend on order or share backend data collide because workers run files in any order",
"Locators stop auto-waiting"
],
"answer": 2,
"explain": "Parallel workers run files in arbitrary order, so order-dependent or shared-state tests break. Each test must be self-contained."
},
{
"q": "When does `page.route(...)` to mock an API make the most sense?",
"choices": [
"For every test, to avoid ever touching the backend",
"When testing front-end behavior deterministically, including error states, while keeping a few real-backend smoke tests",
"Only in CI, never locally",
"Only to speed up the login flow"
],
"answer": 1,
"explain": "Mock to make front-end behavior tests fast and deterministic and to exercise hard-to-trigger states, but keep some unmocked smoke tests that prove the real system connects."
}
]
Check your understanding 3 questions
1. Why save and reuse storage state across tests?
2. What is the main risk introduced by running tests in parallel?
3. When does `page.route(...)` to mock an API make the most sense?