Config, storageState, and the trace viewer
Assemble the Playwright surface, the config, the sign-in-once storageState pattern, locators and fixtures, and the trace viewer you debug with.
You’ve decided Playwright is worth it on four paths: sign-in to a paid surface, the Stripe Checkout round-trip, invitation acceptance, and the primary value loop. The discipline that protects them is settled too: run against a production build, one retry not three, fix flake at the root. This lesson is the wiring. It assembles a complete playwright.config.ts, an auth.setup.ts that signs each role in once and saves the session, the locator and assertion vocabulary, a fixtures file, and the trace viewer you open when something breaks. The next lesson runs the four real paths across it.
Installing and the file layout it generates
Section titled “Installing and the file layout it generates”Two commands scaffold everything. On a fresh project you run the creator, then pull down the browser binaries Playwright drives:
pnpm create playwrightpnpm dlx playwright installThe first writes a config file and a sample test. The second downloads the Chromium, Firefox, and WebKit builds into pnpm’s store, outside your repo and never committed.
- playwright.config.ts the surface this lesson builds
Directorytests/
Directorye2e/ Playwright specs live here
- …
Directoryplaywright-report/ HTML report; gitignored, never committed
- …
Directorytest-results/ traces, screenshots, videos; gitignored, never committed
- …
- .gitignore the creator adds the two dirs above
E2E specs get their own tests/e2e/ directory because Playwright is a separate runner driving the app in a separate process. That contrasts with the integration tests from the “Integration tests across boundaries” chapter, which stay colocated next to the code they exercise as src/**/*.int.test.ts, the suffix that routes them into Vitest’s integration project. Two suites, two homes, both easy to find as the codebase grows.
We pin to the 1.x line, Playwright 1.60, using @playwright/test, which ships TypeScript support so .ts specs need no extra config.
Reading playwright.config.ts
Section titled “Reading playwright.config.ts”Every flake rule from the last lesson shows up here as a config key: production build, one retry, structural fixes, artifacts only on failure. Read the top-level config a group at a time.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({ testDir: 'tests/e2e', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 1 : 0, workers: process.env.CI ? 2 : undefined, reporter: process.env.CI ? [['github'], ['html']] : 'list', use: { baseURL: 'http://localhost:3000', trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure', }, // webServer and projects covered below});Where the specs live, and parallel by default: every spec file runs in its own worker process at once. Same instinct as the Vitest workers you know, on a new runner.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({ testDir: 'tests/e2e', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 1 : 0, workers: process.env.CI ? 2 : undefined, reporter: process.env.CI ? [['github'], ['html']] : 'list', use: { baseURL: 'http://localhost:3000', trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure', }, // webServer and projects covered below});forbidOnly makes a stray test.only fail the CI run instead of silently skipping the rest of the suite. !!process.env.CI reads “true on CI, false locally”: a focused test is fine while you iterate, fatal once it’s pushed.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({ testDir: 'tests/e2e', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 1 : 0, workers: process.env.CI ? 2 : undefined, reporter: process.env.CI ? [['github'], ['html']] : 'list', use: { baseURL: 'http://localhost:3000', trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure', }, // webServer and projects covered below});One retry on CI, zero locally. The scaffold ships retries: 2; the course overrides it to 1 on purpose, since one re-run tells a flake from a real failure while more re-runs hide real bugs behind a green check. workers: 2 caps CI parallelism so a shared runner doesn’t thrash.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({ testDir: 'tests/e2e', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 1 : 0, workers: process.env.CI ? 2 : undefined, reporter: process.env.CI ? [['github'], ['html']] : 'list', use: { baseURL: 'http://localhost:3000', trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure', }, // webServer and projects covered below});GitHub annotations plus an HTML report on CI, a plain scrolling list locally. The github reporter is what surfaces a failure inline on the pull request.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({ testDir: 'tests/e2e', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 1 : 0, workers: process.env.CI ? 2 : undefined, reporter: process.env.CI ? [['github'], ['html']] : 'list', use: { baseURL: 'http://localhost:3000', trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure', }, // webServer and projects covered below});baseURL lets every spec write page.goto('/billing') instead of the full origin. The other three spend bytes on traces, screenshots, and video only on failure. Watch trace: 'on-first-retry', the whole trace-viewer section depends on it.
Two keys, webServer and projects, carry enough weight for their own subsections.
Pointing webServer at a production build
Section titled “Pointing webServer at a production build”webServer makes the rule test the build users get, not next dev executable, in four fields:
webServer: { command: 'pnpm build && pnpm start', url: 'http://localhost:3000', reuseExistingServer: !process.env.CI, timeout: 120_000,},The command builds, then starts, never next dev. Dev mode is a different code path: no static optimization, dev-only error overlays, unminified hydration. A test that passes against next dev and breaks against the real build is the bug that ships, so the config never lets you run against dev by accident.
The url is a readiness probe: Playwright polls it and won’t start a test until the server answers, so your first test isn’t racing the boot. reuseExistingServer: !process.env.CI reuses an already-running server locally for a fast inner loop, while CI always builds fresh from a clean tree. timeout: 120_000 gives the probe two minutes, since a real next build takes a while.
Browser projects and the setup dependency
Section titled “Browser projects and the setup dependency”The last block is projects, where the auth model gets wired in. A project is a named run configuration: a set of tests plus the settings they run under.
projects: [ { name: 'setup', testMatch: /.*\.setup\.ts/ }, { name: 'chromium', use: { ...devices['Desktop Chrome'], storageState: '.auth/owner.json' }, dependencies: ['setup'], },],The setup project grabs every *.setup.ts file and nothing else. The chromium project runs your specs and declares dependencies: ['setup'], which forces setup to finish first; then use.storageState loads the file setup wrote into every chromium test. That dependencies key connects the next section’s auth.setup.ts to all your tests. The .auth/owner.json it points at doesn’t exist yet, and producing it is the setup project’s whole job.
Chromium is the only browser CI runs by default. WebKit and Firefox are two more projects, gated behind an environment flag so they’re opt-in:
// only when PLAYWRIGHT_PROJECTS=all{ name: 'webkit', use: { ...devices['Desktop Safari'], storageState: '.auth/owner.json' }, dependencies: ['setup'] },Cross-browser coverage is expensive, so it’s reserved for the auth and checkout money paths, with a single browser everywhere else: pay for broad coverage exactly where failure costs money, and nowhere else.
Signing in once with auth.setup.ts and storageState
Section titled “Signing in once with auth.setup.ts and storageState”The instinct from other test frameworks is to log in at the top of every test: navigate to /sign-in, fill the form, submit, wait for the dashboard, then test what you care about. Do that, and every money-path test replays the full email-to-password-to-redirect flow before any real work, multiplying the suite’s runtime and re-testing login inside tests that have nothing to do with login.
The fix is to sign each role in once, capture the resulting browser session (cookies plus storage) to a file, and have every other test load that file to start already authenticated. That serialized session is storageState . The login flow runs once, in the setup project, producing the .auth/owner.json file the config already points at.
Here’s the setup file.
import { test as setup, expect } from './fixtures';
setup('authenticate as owner', async ({ page }) => { await page.goto('/sign-in'); await page.getByLabel(/email/i).fill('owner@e2e.test'); await page.getByLabel(/password/i).fill('correct-horse'); await page.getByRole('button', { name: /sign in/i }).click();
await expect( page.getByRole('heading', { name: /dashboard/i }), ).toBeVisible();
await page.context().storageState({ path: '.auth/owner.json' });});An ordinary test in the setup project. It imports test, aliased to setup for readability, and expect from the project’s own fixtures file rather than from @playwright/test. We unpack that fixtures file a couple of sections from here.
import { test as setup, expect } from './fixtures';
setup('authenticate as owner', async ({ page }) => { await page.goto('/sign-in'); await page.getByLabel(/email/i).fill('owner@e2e.test'); await page.getByLabel(/password/i).fill('correct-horse'); await page.getByRole('button', { name: /sign in/i }).click();
await expect( page.getByRole('heading', { name: /dashboard/i }), ).toBeVisible();
await page.context().storageState({ path: '.auth/owner.json' });});Drive the real login UI. The role-first locators, getByLabel and getByRole, are the same ladder you learned for React Testing Library, with page. in place of screen.. The credentials belong to a seeded test user we’ll create next.
import { test as setup, expect } from './fixtures';
setup('authenticate as owner', async ({ page }) => { await page.goto('/sign-in'); await page.getByLabel(/email/i).fill('owner@e2e.test'); await page.getByLabel(/password/i).fill('correct-horse'); await page.getByRole('button', { name: /sign in/i }).click();
await expect( page.getByRole('heading', { name: /dashboard/i }), ).toBeVisible();
await page.context().storageState({ path: '.auth/owner.json' });});Wait for the post-login state before saving. Serialize while the redirect is still in flight and you capture a half-finished login, which fails every test that loads it. The matcher waits for the dashboard heading to appear. (More on why this expect waits a couple of sections from here.)
import { test as setup, expect } from './fixtures';
setup('authenticate as owner', async ({ page }) => { await page.goto('/sign-in'); await page.getByLabel(/email/i).fill('owner@e2e.test'); await page.getByLabel(/password/i).fill('correct-horse'); await page.getByRole('button', { name: /sign in/i }).click();
await expect( page.getByRole('heading', { name: /dashboard/i }), ).toBeVisible();
await page.context().storageState({ path: '.auth/owner.json' });});The write. context().storageState({ path }) serializes the authenticated session to disk, producing the artifact every spec consumes.
You write one auth.setup.ts per role, because the money paths assert role-specific UI: an owner sees billing controls a member doesn’t. Each one signs in a different seeded user and writes its own state file: .auth/owner.json, .auth/member.json, and so on.
The two approaches side by side:
test('owner can open billing', async ({ page }) => { await page.goto('/sign-in'); await page.getByLabel(/email/i).fill('owner@e2e.test'); await page.getByLabel(/password/i).fill('correct-horse'); await page.getByRole('button', { name: /sign in/i }).click(); await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
await page.goto('/billing'); // the test you actually meant to write starts here});Multiplies runtime and re-tests login everywhere. Every spec pays the full email-to-redirect cost, and one login regression fails dozens of unrelated tests at once, burying the signal.
test('owner can open billing', async ({ page }) => { await page.goto('/billing'); // already signed in — straight to the point});Log in once in setup, reuse everywhere. The session loaded from .auth/owner.json starts this test authenticated, and login is exercised in exactly one place: the dedicated sign-in money-path test.
Why E2E uses a separate database, reset per run
Section titled “Why E2E uses a separate database, reset per run”Playwright drives a real server, and a real server needs a real database. You accepted “separate Postgres for tests” back in “Integration tests across boundaries,” but that suite’s database setup won’t transfer here.
The integration tests keyed a fresh database per Vitest worker and wrapped each test in a transaction they rolled back, which made every test start clean. That works because the test and the database connection run in the same process: one runtime opens the transaction, writes, and rolls back. Playwright’s server is a separate process your test only reaches over HTTP, so the test never holds the server’s transaction and cannot roll it back. The process boundary that makes E2E realistic is what rules out per-test rollback.
So the isolation model changes: a full reset between runs instead of a rollback per test. The course uses a separate database, saas_e2e, with a reset script:
pnpm db:e2e:resetThat script runs the migrations, then a deterministic seed. The seed creates the role users (owner@e2e.test, member@e2e.test), their organizations, and a baseline of records every test can assume exists. CI runs it once before the suite; locally you reuse the seeded database until the schema changes. Tests write on top of the seed, and the reset between runs isolates them. Those seeded users are the exact credentials auth.setup.ts signs in.
| Layer | Database | Isolation seam |
|---|---|---|
| Integration Integration tests at the seams | a fresh database per worker keyed by VITEST_POOL_ID | a transaction rolled back after each test withRollback(tx) per test |
| E2E this chapter | one shared database saas_e2e | a deterministic full reset between runs db:e2e:reset per run |
Keep the two databases separate. Point Playwright at the integration suite’s database, or run both against one, and their writes collide.
Auto-waiting locators and assertions
Section titled “Auto-waiting locators and assertions”A test finds elements with locators and checks them with expect. Both auto-wait, which removes a whole category of timing bugs and retires the second anti-pattern, page.waitForTimeout.
Locators are role-first, and they wait
Section titled “Locators are role-first, and they wait”The locator priority ladder is the one you learned for React Testing Library: getByRole first, then getByLabel, then getByText, and getByTestId only as a last resort. The one difference is the prefix, page. instead of screen.:
page.getByRole('button', { name: /sign in/i });page.getByLabel(/email/i);page.getByText(/invoice sent/i);page.getByTestId('plan-badge');Query by what a user perceives, such as a button named “Sign in,” not by how the markup happens to be built today. That’s why CSS-class locators don’t belong: page.locator('.btn-primary') breaks the moment someone renames the class, for a reason that has nothing to do with behavior. A role plus an accessible name survives that refactor.
The new part is what happens when you act on a locator. Playwright locators are auto-waiting : await page.getByRole('button', { name: /pay/i }).click() waits until the button is attached, visible, not animating, and enabled, then clicks. No waitForSelector first, because the wait is built into the action.
expect polls until it holds
Section titled “expect polls until it holds”Assertions work the same way. A web-first matcher like toBeVisible is not a one-shot check:
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();It polls: it re-checks the condition until it holds or the default timeout, about five seconds, runs out. So after you click “Sign in,” you don’t sleep to let the redirect happen; you assert the dashboard heading is visible, and the assertion waits for it. The matchers you’ll reach for most are toBeVisible, toHaveURL, toHaveText, toBeEnabled, and toBeChecked. To wait on something not in the DOM, such as a database row appearing or a webhook landing, expect.poll(fn) re-runs your function until the value matches.
Auto-waiting locators and polling matchers are why “sleep, then assert” is obsolete:
await page.getByRole('button', { name: /sign in/i }).click();await page.waitForTimeout(2000);expect(await page.getByRole('heading', { name: /dashboard/i }).isVisible()).toBe(true);Flaky and slow at once. 2000ms is a guess: too short and it fails on a slow run, too long and every test pays the full wait even when the page was ready in 200ms. A fixed sleep can only be wrong in one of two directions.
await page.getByRole('button', { name: /sign in/i }).click();await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();Deterministic, and as fast as the page allows. The matcher polls until the heading appears, then continues immediately. This is the structural fix flake discipline asks for, not a retry bump.
One subtle, expensive trap: a web-first matcher only runs if you await it.
expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();Drop the await and the matcher returns a promise nobody waits on. The test moves past it without checking the condition, so it passes silently instead of failing, giving you false confidence. The fix is one keyword: put await back.
Now a quick check on the locator instinct.
Your checkout page renders this markup, and a money-path test needs to click the Confirm payment button:
<form> <label>Card number <input name="cardNumber" /></label> <button class="btn-pay primary" data-testid="checkout-submit"> Confirm payment </button></form>Which locator targets that button and survives a CSS-class rename or a markup reshuffle in next month’s refactor?
page.locator('.btn-pay.primary');page.getByRole('button', { name: /confirm payment/i });page.locator('form button >> nth=0');page.getByTestId('checkout-submit');page.getByRole('button', { name: /confirm payment/i }) matches by what a user perceives, a button labelled “Confirm payment,” so it rides through class renames and DOM reordering untouched. The class selector breaks when someone renames .btn-pay; the positional nth=0 breaks if another button is added above it; and getByTestId works but sits at the bottom of the ladder, for when no role or label fits. Here a role plus its accessible name fits.
Packaging shared setup with fixtures
Section titled “Packaging shared setup with fixtures”Your specs import test and expect from ./fixtures. That file does two jobs. First, it re-exports test and expect so every spec imports from one local file instead of from @playwright/test directly, the same single-import pattern you used for the React Testing Library render helper. Second, it defines custom fixtures with base.extend, typed and per-test, the same isolation discipline Vitest gave you on a new surface. The await use(...) call in the middle is the part to slow down on if you’re coming from beforeEach.
import { test as base, expect } from '@playwright/test';import { seedInvoices, deleteInvoices } from './seed-helpers';
export const test = base.extend<{ invoices: Invoice[] }>({ invoices: async ({}, use) => { const invoices = await seedInvoices(3); await use(invoices); await deleteInvoices(invoices); },});
export { expect };base.extend takes a generic describing the fixtures this file adds, here an invoices fixture typed as Invoice[]. The returned test is what specs import, fully typed and aware of the new fixture.
import { test as base, expect } from '@playwright/test';import { seedInvoices, deleteInvoices } from './seed-helpers';
export const test = base.extend<{ invoices: Invoice[] }>({ invoices: async ({}, use) => { const invoices = await seedInvoices(3); await use(invoices); await deleteInvoices(invoices); },});
export { expect };Setup. Code above use runs before the test; here it seeds three invoices.
import { test as base, expect } from '@playwright/test';import { seedInvoices, deleteInvoices } from './seed-helpers';
export const test = base.extend<{ invoices: Invoice[] }>({ invoices: async ({}, use) => { const invoices = await seedInvoices(3); await use(invoices); await deleteInvoices(invoices); },});
export { expect };await use(invoices) hands the value to the test and pauses here for its duration. This is what replaces beforeEach: setup and teardown share one function, split by the use call.
import { test as base, expect } from '@playwright/test';import { seedInvoices, deleteInvoices } from './seed-helpers';
export const test = base.extend<{ invoices: Invoice[] }>({ invoices: async ({}, use) => { const invoices = await seedInvoices(3); await use(invoices); await deleteInvoices(invoices); },});
export { expect };Teardown. Code after use runs once the test finishes, deleting the rows it seeded. Fixtures are per-test, so this runs for every test that asks for invoices.
A spec opts into a fixture by naming it in the destructured argument, async ({ invoices }) =>, and Playwright runs the setup, injects the value, and runs the teardown around that one test. Same per-test isolation guarantee as the integration layer, in browser-test form.
Debug a failed test with the trace viewer
Section titled “Debug a failed test with the trace viewer”This is what trace: 'on-first-retry' buys you. When a test fails and gets its one retry, that retry records a trace.zip. You open it with:
pnpm exec playwright show-trace test-results/<the-failing-test>/trace.zipWhat opens is not a log file but the trace viewer , a timeline of every action. Click one and you see the page’s full DOM snapshot at that moment, the network requests in flight, the console output, a screenshot, and the source-mapped line of your test that fired it. Instead of sprinkling console.log through a spec, you scrub to the action that failed.
The fastest way to understand it is to operate one. The trace below is a real, failing Playwright run from the official sample suite, and it’s live:
Click an action in the timeline and watch the DOM snapshot on the left swap to the page as it looked at that step. Open the Network tab to see the requests that action triggered. Read the Call log to follow the test line by line. Hover the timeline to scrub through the run like video. That’s the whole debugging loop, and you just ran it without writing a failing test of your own.
CI runs the same loop with one extra hop: the trace.zip uploads as a GitHub Actions artifact, and whoever reviews the pull request downloads it and opens it with show-trace. The failure travels with its own evidence.
A trace exists only when a test retries. With trace: 'on-first-retry', a test that passes the first time records nothing, which is what you want; a trace for every green run would fill up your artifacts. So “why is there no trace?” almost always means the test didn’t retry: it passed first try, or the config doesn’t capture traces.
Record a first draft with codegen
Section titled “Record a first draft with codegen”You don’t have to hand-write the first version of a spec. Point codegen at your running app and click through the flow:
pnpm exec playwright codegen http://localhost:3000A browser opens and records your clicks and typing as Playwright code, generating locators as you go, a fast way to rough out a new test. The one place judgment comes in: codegen sometimes emits a CSS selector where a role-based locator would be more robust. So the workflow is codegen for the skeleton, then rewrite the brittle locators role-first before review. The VS Code extension wraps the same tools into the editor: run or debug a single test, view its trace live, and pick locators interactively.
What stays out of this surface
Section titled “What stays out of this surface”A few deliberate cuts, so you know where the edges are:
- The trace viewer panel by panel. You’ve operated it; the official docs own the exhaustive tour.
- Custom reporters. Pinned to GitHub plus HTML, no others.
- Sharding. Splitting the suite across parallel CI jobs (
--shard=1/4,--shard=2/4) only pays off past roughly 10–15 minutes of runtime; a four-money-path suite is nowhere near that. - Component testing in Playwright. React Testing Library owns that layer, and the course won’t pin two component-test tools.
- Mobile emulation and visual regression.
page.clock. It controlsDate.now()inside the browser, like Vitest’s fake timers, for time-based UI such as a trial-end banner; reach for it only when a money path needs to freeze time.
External resources
Section titled “External resources”The official docs go deeper on CI setup and the trace viewer, and the best-practices page is worth reading for its locator and isolation guidance.