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:
webServerstarts the app before the tests and waits until/healthzanswers.NODE_ENV=testturns on the test-only data factory (§76). It uses its own port, withreuseExistingServer: 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.retriesonly in CI. Locally a failure should fail loudly. In CI a retry plustrace: "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.
setupruns first because the others list it independencies.mobileemulates 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.