Skip to content
advanced Phase 13 · Testing Next.js Applications

End-to-End Tests with Playwright

Cover critical user journeys in a real browser.

1h
0 problems
Topic Progress 0%

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?

Question 1 options

2. A suite logs in through the UI in every test and takes 14 minutes. What preserves coverage while cutting the time?

Question 2 options

3. A test does `await page.waitForTimeout(2000)` then `expect(await el.isVisible()).toBe(true)`, and it fails only on slow CI runners. Why?

Question 3 options

4. Why does the auth setup project exist as a separate project with dependencies instead of a globalSetup file that logs in?

Question 4 options

Flashcards

Question

What does Playwright's webServer block do, and which command should a Next.js suite use?

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?

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?

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?

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?

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.

Continue Learning