Booting Playwright Against the Production Build with webServer
Playwright's webServer setting is what turns npx playwright test from "browser driver" into "full system check": it boots your app, waits until it responds, runs the suite, and shuts it down. The only real decision is which server it boots — and for Next.js the answer should be the production build, not next dev.
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'list',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: '.auth/user.json' },
dependencies: ['setup'],
},
],
webServer: {
command: 'npm run build && npm run start',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
})
Why the production build matters — Testing Server Actions in Isolation proved your logic in milliseconds, but dev mode hides an entire class of problems:
next dev |
next build && next start |
|
|---|---|---|
| Compilation | on demand, per route | ahead of time, ahead of traffic |
| Type & lint errors | surfaced as overlays, often tolerated | fail the build |
| Env validation | lazy, at request time | runs at build; missing vars stop you |
| Bundle size / tree shaking | unbundled modules | real production graph |
| Route behavior | HMR websocket, dev-only warnings | what ships |
The timeout: 120_000 line exists because the first run has to finish compiling; the usual failure mode is Playwright giving up at 30 s while next build is still type-checking. And reuseExistingServer: false in CI is not optional: a stale server on port 3000 — someone's forgotten dev process, or a previous build — would happily answer your assertions with outdated code.
Two operational details bite teams exactly once each. First, environment: next build inlines NEXT_PUBLIC_* values at build time, so the test build must receive the public variables the suite expects — via .env.production, or explicitly in the env field of the webServer block. A missing one does not crash the build; it inlines undefined where a feature flag or API base should be, and your assertions fail with a diff that points at the app instead of the config. Second, Playwright fails the whole run when the server command exits non-zero — a broken build surfaces as a webServer error, not as forty mysterious test failures. Read that message first; it is the build telling you what went wrong.
Parallelism is where the time goes back. fullyParallel: true runs test files concurrently across workers (Playwright defaults to half your cores), each with an isolated browser context, so state never leaks between tests. On a CI runner the suite splits across machines with sharding:
npx playwright test --shard=1/4 # runner A
npx playwright test --shard=2/4 # runner B
# ...each uploads its own HTML report and trace artifacts
A 12-minute suite becomes three minutes across four shards, and the matrix in your workflow file is four --shard=$i/$N jobs feeding one merged report. Two rules keep shards honest: no test may depend on another's data (unique rows per test, covered next chapter), and flaky tests get retries rather than --workers=1 — serializing the suite to calm one test taxes every future run.
With the app booting for real, the next problem is getting the suite past your login screen on every run instead of typing a password two hundred times.
Logging In Once: Reusing Sessions with storageState and an Auth Setup Project
Write the login flow into every test and you get two gifts: a suite that takes ten times longer, and one that fails whenever the sign-in form changes. Playwright's answer is to authenticate once and reuse the resulting cookies and storage.
// e2e/auth.setup.ts
import { test as setup, expect } from '@playwright/test'
const authFile = '.auth/user.json'
setup('authenticate', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!)
await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!)
await page.getByRole('button', { name: 'Sign in' }).click()
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible()
await page.context().storageState({ path: authFile })
})
The setup project runs first because chromium declares dependencies: ['setup']; once the file has written .auth/user.json, every test in that project boots with storageState applied — cookies, localStorage, and session tokens restored before the first navigation. To page.goto('/orders'), you are simply logged in.
Each worker process loads the storage file independently, so parallelism never contends for a session. The pieces around it:
// in playwright.config.ts
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{ name: 'chromium', use: { ...devices['Desktop Chrome'], storageState: '.auth/user.json' },
dependencies: ['setup'] },
{ name: 'guest', use: { ...devices['Desktop Chrome'] } }, // no storage — public pages
]
| Strategy | Cost per test | What it covers |
|---|---|---|
| UI login every test | ~1–3 s + flake source | login itself, nothing else gained |
storageState reuse |
~0 s (file read) | everything after login |
| API-seeded session cookie | ~0 s | app flows, but not the login form |
Fresh browser.newContext() |
full setup | logout and session-expiry tests |
Two supporting moves keep it reliable. First, seed your own data through an endpoint, not the UI: a test-only route guarded by an env var (POST /api/test/seed creating user+orders@e2e.test) or a direct API call via Playwright's request fixture means each shard starts from known rows — required for sharding, where tests run on different machines and cannot share state. Second, keep exactly one test that logs in through the UI. Session reuse will happily hide a login form whose button lost its accessible name; a single login.spec.ts in the guest project holds that line.
Tests that mutate the session — logout, "switch account", clearing cart on sign-out — must not run with shared storage. Give them an isolated context:
test('logout clears the session', async ({ browser }) => {
const context = await browser.newContext({ storageState: authFile })
const page = await context.newPage()
await page.goto('/account')
await page.getByRole('button', { name: 'Log out' }).click()
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible()
await context.close()
})
Wiring this into CI has three moving parts worth naming. Credentials arrive as secrets (TEST_USER_EMAIL, TEST_USER_PASSWORD) — never a checked-in file — and the generated .auth/user.json belongs in .gitignore, because it holds a live session token. And when login runs through an external identity provider whose UI you don't control, the sign-in page becomes the flaky part of your suite; sidestep it by minting the session through your own API and copying the cookies into the browser context:
setup('seed a session without the login UI', async ({ context, request }) => {
await request.post('/api/auth/session', { data: { email, password } })
await context.addCookies(await request.cookies())
await context.storageState({ path: authFile })
})
Either path — UI once or API once — preserves the invariant: authentication is setup cost paid once per run, never per test. That is what keeps a real-browser suite fast enough to run on every push, which is the only way end-to-end tests pay for themselves. What remains is writing the journey steps themselves so they survive your next redesign, the subject of the final chapter of this phase.
Locators and Web-First Assertions: Journeys That Survive Redesigns
Every flaky end-to-end test in existence shares one root cause: it asserted something at the wrong moment or found it the wrong way. Playwright gives you both fixes — locators that wait, and assertions that retry — and the discipline is knowing to use nothing else.
test('checkout charges the card and issues a receipt', async ({ page }) => {
await page.goto('/products/slate-mug')
await page.getByRole('button', { name: /add to cart/i }).click()
await page.getByRole('link', { name: /cart/i }).click()
await page.getByRole('button', { name: /checkout/i }).click()
await page.getByLabel('Card number').fill('4242 4242 4242 4242')
await page.getByRole('button', { name: /pay/i }).click()
await expect(page.getByRole('heading', { name: /receipt/i })).toBeVisible()
await expect(page.getByText(/order #\d+/)).toBeVisible()
})
Not one explicit wait appears, and that is deliberate. getByRole(...) does not resolve the element immediately — every action (.click(), .fill()) retries until the locator resolves and the actionability checks pass, and expect(locator).toBeVisible() polls until true or the timeout expires. These are web-first assertions: they wait for the world, instead of hoping the world kept up with your code.
The anti-patterns to refuse on sight:
| Tempting | Why it flakes | Instead |
|---|---|---|
await page.waitForTimeout(2000) |
assumes a machine speed CI can't guarantee | let the locator or assertion wait |
expect(await el.isVisible()).toBe(true) |
isVisible() answers now, no waiting |
await expect(locator).toBeVisible() |
page.waitForLoadState('networkidle') |
long-lived connections never idle | wait for the specific element |
page.locator('.btn-3') |
positional/CSS coupling | getByRole('button', { name: /pay/i }) |
shared test user email across tests |
unique-constraint collisions under parallel | crypto.randomUUID() per test |
Role-based locators are also what makes the suite survive copy and markup changes — the same principle you applied in Component Tests with Testing Library, now enforced by a real browser. The accessible name can change ("Pay" → "Pay now") without breaking a /pay/i matcher, while a class rename or a <div>-to-<button> upgrade — which improves accessibility — breaks zero locators. When a test needs something with no role, prefer getByLabel for fields and getByTestId last, exactly as in the component layer.
External calls are the remaining flake source you control. Payments, maps, and rate-limited APIs get intercepted:
await page.route('**/api/payments/**', (route) =>
route.fulfill({ json: { ok: true, chargeId: 'ch_test_1' } }))
so the journey exercises your code while the vendor behaves perfectly — deterministically, in every shard.
Two Playwright mechanics do silent work worth understanding. Locators used by actions pass actionability checks — the element must be visible, stable (not moving), enabled, and able to receive events — so a click waits out your entry animations and a disabled submit button queues the click until it enables, instead of failing on the first frame. And long journeys read better in the trace when narrated:
await test.step('checkout', async () => {
await page.getByRole('button', { name: /checkout/i }).click()
await page.getByLabel('Card number').fill('4242 4242 4242 4242')
await page.getByRole('button', { name: /pay/i }).click()
await expect(page.getByRole('heading', { name: /receipt/i })).toBeVisible()
})
A failure then names the phase — "checkout" — instead of a line number thirty steps in.
When a test still fails, read the artifact before editing code: trace: 'on-first-retry' recorded a screencast, DOM snapshots, and the network log, and the Playwright trace viewer shows exactly which locator was waiting and what the page contained at that instant. The triage loop is symptom → trace → fix — usually a locator or a shared-state issue, occasionally a genuine race in the app that the test just caught.
That closes the phase: unit tests pinned your helpers, component tests pinned your rendering, route-handler and action tests pinned your server logic, and this layer pins the journey that crosses all of them against a production build. The next topic leaves testing behind for the render pipeline itself — Optimizing Images with next/image — where several of the vitals you have been guarding are decided.
Quiz
1. Your Playwright config uses `command: 'next dev'` for webServer and CI failures never reproduce locally. What is the strongest fix?
2. A suite logs in through the UI in every test and takes 14 minutes. What preserves coverage while cutting the time?
3. A test does `await page.waitForTimeout(2000)` then `expect(await el.isVisible()).toBe(true)`, and it fails only on slow CI runners. Why?
4. Why does the auth setup project exist as a separate project with dependencies instead of a globalSetup file that logs in?
Flashcards
Question
What does Playwright's webServer block do, and which command should a Next.js suite use?
Click to reveal answer
Answer
It starts the app before the run, waits for the URL to respond, and tears it down after. For Next.js use `npm run build && npm run start` so tests run against the production bundle — type/lint failures stop the build, env is validated up front, and the module graph is the real one. Set timeout high enough for the build and reuseExistingServer false in CI.
Question
How does test authentication reuse work end to end?
Click to reveal answer
Answer
An auth setup project (declared first, matched by testMatch /auth.setup.ts) performs one UI login and writes `page.context().storageState({ path: '.auth/user.json' })`; the main project sets `dependencies: ['setup']` and `use: { storageState }`, so each worker starts already authenticated. Session-mutating tests instead get a fresh browser.newContext().
Question
How do you split one Playwright run across four CI machines, and what must be true of the tests?
Click to reveal answer
Answer
`npx playwright test --shard=1/4` … `--shard=4/4`, each uploading its own report/trace artifacts. Tests must be shard-safe: no shared fixtures or fixed emails (use crypto.randomUUID() per test), seed data through a test-only endpoint rather than assuming another shard created it, and keep flakes handled by retries instead of serializing workers.
Question
What makes a Playwright assertion 'web-first', and which anti-patterns violate it?
Click to reveal answer
Answer
It polls the DOM until the condition holds or the timeout expires — e.g. expect(locator).toBeVisible(). Violations: waitForTimeout sleeps (bet on machine speed), `await el.isVisible()` (answers for the present instant), waitForLoadState('networkidle') (long-lived connections never idle), and positional/CSS selectors that break on markup changes.
Question
Why prefer getByRole locators in E2E tests, and how do you handle flaky external APIs?
Click to reveal answer
Answer
Role + accessible-name locators target what users perceive, so class renames, wrapper changes, and even div→button upgrades don't break them — the same priority you use in component tests. Stub third-party calls with page.route(...route.fulfill({ json })) so vendors behave deterministically in every shard, and debug failures in the trace viewer (trace: 'on-first-retry').
Revision Notes
Key Takeaways
- 1. Point webServer at `npm run build && npm run start`: production fails on type errors, validates env at build time, and ships the real bundle — dev mode hides exactly the class of bugs E2E exists to catch
- 2. Scale with fullyParallel workers and CI `--shard=i/N`; tests must generate their own data (unique emails, seeded via test endpoint) because shards run on different machines
- 3. Authenticate once: an auth setup project writes storageState to .auth/user.json, dependent projects load it per worker, and logout-style tests get a fresh browser.newContext()
- 4. Web-first assertions and locators do the waiting — expect(...).toBeVisible() polls, getByRole(...).click() retries actionability; sleeps, immediate isVisible(), and networkidle are the flake triad
- 5. Intercept vendor calls with page.route, capture traces with trace: 'on-first-retry', and triage from the trace viewer — symptom, then locator or shared state, then fix
Interview Tips
- • "Why test against the production build?" — name three things dev mode hides: on-demand compilation, tolerated type/lint errors, and unbundled modules; then mention env validation happening at build time and reuseExistingServer:false guarding against stale servers
- • "How do you keep E2E fast?" — session reuse via storageState instead of per-test logins, fullyParallel workers plus CI sharding, and stubbed third-party calls; add that you never buy speed by serializing workers to hide one flaky test
- • "How do you write a test that doesn't flake?" — never assert the present: web-first assertions poll, locators retry actionability, pending states are pinned with route interception; fixed sleeps and isVisible() are the two signs someone didn't know Playwright waits for you
- • "Your test passes locally but fails in CI — how do you debug?" — traces: trace on-first-retry plus screenshot on failure, open the trace viewer to see the locator's retries and the DOM at each step; then ask whether the test shared state (fixed email, seeded rows) across shards
Cheat Sheet
End-to-End Tests with Playwright — Cheat Sheet
Core config
webServer: {
command: 'npm run build && npm run start', // production, not `next dev`
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI, // false in CI — stale-server trap
timeout: 120_000, // first build needs it
},
use: { baseURL: 'http://localhost:3000', trace: 'on-first-retry', screenshot: 'only-on-failure' },
retries: process.env.CI ? 2 : 0,
fullyParallel: true,
Auth once
// e2e/auth.setup.ts — project { name: 'setup', testMatch: /auth\.setup\.ts/ }
await page.context().storageState({ path: '.auth/user.json' })
// main project: dependencies: ['setup'], use: { storageState: '.auth/user.json' }
Session-mutating tests (logout) → browser.newContext({ storageState }) fresh. Keep one UI-login test in a guest project.
Scale
npx playwright test --shard=1/4 # CI matrix: 4 jobs, merged report
Shard-safe = unique data per test (crypto.randomUUID()), seeded via test-only endpoint, flakes handled by retries — never workers=1.
Waiting discipline
| Use | Avoid |
|---|---|
expect(loc).toBeVisible() (polls) |
waitForTimeout(ms) |
getByRole('button', { name: /pay/i }) (retries actionability) |
locator('.btn-3'), CSS classes |
getByLabel for fields, getByTestId last |
expect(await el.isVisible()) — no wait |
page.route(...) + route.fulfill({ json }) for vendors |
waitForLoadState('networkidle') |
Triage: failure → trace viewer (screencast, DOM, network) → locator or shared state → fix. Journeys assert what the user sees; data comes from your seed endpoint, not other tests.