Frontend Labs
⋯

75. Setting up Playwright for a real project

cd JS/projects/support-desk
npm install -D @playwright/test @axe-core/playwright
npx playwright install chromium          # or: --with-deps on Linux CI
npm test                                 # = playwright test
support-desk/
├── server.mjs              the app (node:http, zero runtime dependencies)
├── public/                 the client (ES modules)
├── playwright.config.js
└── tests/
    ├── fixtures.js         page objects, fixtures, helpers  (§76)
    ├── auth.setup.js       signs in once, saves the session (§75)
    ├── login.spec.js       sign-in, cookies, logout, visual  (§76–§77)
    ├── chat.spec.js        streaming, cards, approval, mocks (§76–§77)
    ├── security.spec.js    headers, CSRF, IDOR, rate limits  (§77)
    ├── session.spec.js     idle timeout with a fake clock    (§77)
    └── a11y.spec.js        axe-core scan                     (§77)

The config, line by line

// playwright.config.js
import { defineConfig, devices } from "@playwright/test";

const PORT = 3101; // not the dev port: tests always get a fresh server in test mode

export default defineConfig({
  testDir: "tests",
  fullyParallel: true,
  forbidOnly: !!process.env.CI, // a stray test.only must fail CI, not silently skip the suite
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 2 : undefined,
  reporter: process.env.CI ? [["github"], ["html", { open: "never" }]] : [["list"]],
  use: {
    baseURL: `http://127.0.0.1:${PORT}`,
    trace: "on-first-retry", // a full time-travel trace of any test that flaked
    screenshot: "only-on-failure",
  },
  expect: {
    toHaveScreenshot: { maxDiffPixelRatio: 0.01, animations: "disabled" },
  },
  webServer: {
    command: "node server.mjs",
    url: `http://127.0.0.1:${PORT}/healthz`,
    // Every test shares Alice's session, so the per-session chat limit would
    // throttle the suite itself. Tests of the limiter use their own keys.
    env: { PORT: String(PORT), NODE_ENV: "test", CHAT_LIMIT: "10000" },
    reuseExistingServer: false,
  },
  projects: [
    // 1. Sign in once per user and save the cookies.
    { name: "setup", testMatch: /auth\.setup\.js/ },
    // 2. Everything else starts already signed in as Alice.
    {
      name: "chromium",
      use: { ...devices["Desktop Chrome"], storageState: "playwright/.auth/alice.json" },
      dependencies: ["setup"],
    },
    {
      name: "mobile",
      use: { ...devices["Pixel 7"], storageState: "playwright/.auth/alice.json" },
      dependencies: ["setup"],
      grep: /@mobile/,
    },
  ],
});

The decisions that matter:

  • webServer starts the app before the tests and waits until /healthz answers. NODE_ENV=test turns on the test-only data factory (§76). It uses its own port, with reuseExistingServer: false, so a dev server you left running (without test mode, with stale data) is never used by mistake. That was the first of the §78 bugs.
  • retries only in CI. Locally a failure should fail loudly. In CI a retry plus trace: "on-first-retry" gives you a full recording of every flake to fix, without blocking merges on a one-off.
  • Projects run the same tests under different settings. setup runs first because the others list it in dependencies. mobile emulates a Pixel 7 (viewport, touch, user agent, device scale factor) and runs only tests tagged @mobile.

Sign in once: storageState

Logging in through the UI before each of 25 tests would be slow, and it would test the login form 25 times. Instead, a setup project signs in once and saves the browser state:

// tests/auth.setup.js
import { test as setup, expect } from "@playwright/test";
import { login, USERS } from "./fixtures.js";

setup("sign in as Alice", async ({ page }) => {
  await login(page, USERS.alice);
  await expect(page.getByRole("list", { name: "Your orders" })).toBeVisible();
  await page.context().storageState({ path: "playwright/.auth/alice.json" });
});

Every test in the chromium project then starts with Alice's sid cookie already set. Tests about signing in opt out with test.use({ storageState: { cookies: [], origins: [] } }). Add playwright/.auth/ to .gitignore: it contains live session cookies.

Generated from real-life-JS-guide.md · 8 Oct 2026 16:36