# Playwright

Playwright drives a real browser — Chromium, Firefox, or WebKit — through the
actions a user performs. One test therefore exercises routing, rendering, network
calls, and client state together. That reach comes at a cost: a browser test is
slow and runs on asynchrony, the dominant cause of flaky tests at 74 of 161
classified fixes in one analysis (Luo et al. 2014)[^luo2014].

This page targets **Playwright ≥ 1.50** with TypeScript. The method behind it is
[example tests](https://quality.stereobooster.com/example-tests.md) run at the whole-system level,
serving the [functionality](https://quality.stereobooster.com/functionality.md) dimension.

## Setup

```bash
npm init playwright@latest
```

The generator scaffolds `playwright.config.ts`, a `playwright/` test folder,
and a CI workflow. The defaults are reasonable; each setting in the
recommended baseline is a *delta* from them.

### Recommended `playwright.config.ts` baseline

```ts
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  // Parallel by default; serial only when fixtures force it.
  fullyParallel: true,

  // Refuse to run if .only is committed. Catches sloppy PRs.
  forbidOnly: !!process.env.CI,

  // Retry only on CI; locally a flake should fail loud.
  retries: process.env.CI ? 2 : 0,

  // No global timeout cap — but per-action / per-test bounds.
  timeout: 30_000,
  expect: {
    timeout: 5_000,
    // Apply a rendering reset to every screenshot (see Visual regression).
    toHaveScreenshot: { stylePath: "./playwright/screenshot.css" },
  },

  // Organize baselines by project + test file, so cross-project shots
  // don't collide and a PR's diff maps to the test that owns it.
  snapshotPathTemplate:
    "{testDir}/__screenshots__/{projectName}/{testFilePath}/{arg}{ext}",

  // Traces are the operative debugging tool — keep on first retry.
  use: {
    baseURL: process.env.BASE_URL ?? "http://localhost:3000",
    trace: "on-first-retry",
    screenshot: "only-on-failure",
    video: "retain-on-failure",
  },

  reporter: [
    ["list"],
    ["html", { open: "never" }],
    process.env.CI ? ["github"] : null,
  ].filter(Boolean) as any,

  projects: [
    { name: "chromium",      use: { ...devices["Desktop Chrome"] } },
    { name: "mobile-chrome", use: { ...devices["Pixel 7"] } },
    { name: "mobile-safari", use: { ...devices["iPhone 15"] } },
  ],

  webServer: {
    command: "npm run start",
    url: "http://localhost:3000",
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
});
```

## Locator strategy

Locator brittleness is the biggest Playwright maintenance cost. **For UI
you own, default to `getByTestId`** — a `data-testid` is decoupled from
both the copy and the DOM structure, so the test survives rewording,
localization, and markup refactors alike. Use text/role locators
(`getByRole`, `getByText`) only for third-party markup you can't
annotate; CSS/XPath are the last resort.

```ts
// Good — a stable hook; survives rewording, localization, and refactors.
await page.getByTestId("save-changes").click();

// Fragile — breaks when the label is reworded or localized.
await page.getByRole("button", { name: "Save changes" }).click();

// Worst — couples to CSS classes that change on any restyle.
await page.locator(".btn-primary.right-side").click();
```

## Auth-state reuse

Logging in on every test is slow and flaky. Run auth once per
project; serialize the storage state; reuse it across tests.

```ts
// playwright/auth.setup.ts
import { test as setup, expect } from "@playwright/test";

setup("authenticate", async ({ page }) => {
  await page.goto("/login");
  await page.getByTestId("login-email").fill(process.env.E2E_USER!);
  await page.getByTestId("login-password").fill(process.env.E2E_PASS!);
  await page.getByTestId("login-submit").click();
  await expect(page.getByTestId("dashboard")).toBeVisible();
  await page.context().storageState({ path: "playwright/.auth/user.json" });
});
```

Reference the saved state from any project that needs an
authenticated session:

```ts
// playwright.config.ts (excerpt)
projects: [
  { name: "setup", testMatch: /.*\.setup\.ts/ },
  {
    name: "authenticated",
    dependencies: ["setup"],
    use: { storageState: "playwright/.auth/user.json" },
  },
],
```

## Test data

Deterministic data is what makes both assertions and screenshots stable.
Generate it from your schemas rather than hand-rolling fixtures:
[Zocker](https://zocker.sigrist.dev/) produces valid data from a [Zod](https://zod.dev/) schema (the
same schemas the [TypeScript recipe](https://quality.stereobooster.com/typescript.md) defines), and
[faker](https://fakerjs.dev/) fills realistic fields — both with a fixed seed so
every run is identical. Serve it through whichever boundary you chose:
`page.route` for per-test stubs, or [MSW](https://mswjs.io/) when you want one set of
handlers shared across Playwright, [Vitest](https://vitest.dev/), and
[Storybook](https://storybook.js.org/).

```ts
import { faker } from "@faker-js/faker";
import { zocker } from "zocker";

faker.seed(42);
faker.setDefaultRefDate("2025-09-01T10:00:00");

const orders = Array.from({ length: 3 }, () => zocker(OrderSchema).generate());
await page.route("**/api/orders", (route) => route.fulfill({ json: orders }));
```

## Network mocking — choosing the boundary

Playwright runs whatever boundary you point it at, and `page.route` is
how you choose it. There's no single "real" boundary to preserve; stubbing
is a deliberate choice of scope:

- **Stub all network** (`page.route("**/*", …)`) — exercise the frontend
  alone against canned responses: fast, deterministic, no backend
  needed.
- **Stub one dependency** — keep your whole stack real but cut out a
  service that's rate-limited, costly, or unreliable (Stripe sandbox,
  third-party email).
- **Stub nothing** — the full stack against a live backend.

Pick the boundary you're testing at, then stub everything outside it. The
other reason to stub is **control** — a specific failure response is often
the whole point of a test, and the only reliable way to trigger it is to
fulfil it yourself:

```ts
// Test the "card declined" path by stubbing the payments boundary.
await page.route("**/api/payments", (route) => {
  route.fulfill({
    status: 500,
    contentType: "application/json",
    body: JSON.stringify({ error: "Card declined" }),
  });
});
```

**Stub heavy assets** — point images, fonts, and analytics at a tiny
inline placeholder so a slow or flaky CDN can't affect the test:

```ts
await page.route(/\.(png|jpe?g|webp|gif|woff2?)$/, (route) =>
  route.fulfill({
    contentType: "image/svg+xml",
    body: '<svg xmlns="http://www.w3.org/2000/svg"/>',
  }));
```

**Verify the outgoing request** — once you've stubbed the network, the
usual "create an item, then assert it shows up in the list" check isn't
available: nothing was really persisted. So you test at the boundary
instead — assert the action sent the *right request with the right
data*:

```ts
const [request] = await Promise.all([
  page.waitForRequest("**/api/orders"),
  page.getByTestId("place-order").click(),
]);
expect(request.postDataJSON()).toMatchObject({ items: 2 });
```

## Trace-on-failure as the debugging primitive

Playwright traces are the operative debugging tool. They capture
DOM snapshots, network requests, console output, screenshots,
and the test code at each step.

```bash
npx playwright show-trace test-results/example/trace.zip
```

With `trace: "on-first-retry"` in the config, a flaky failure
keeps its trace automatically.

The same trace is the fastest way to debug a **CI-only** failure: upload
`test-results/` as an artifact on failure, then pull it down and replay
it on your machine.

```yaml
# .github/workflows/e2e.yml (excerpt)
- run: npx playwright test --project "${{ matrix.browser }}"
- uses: actions/upload-artifact@v5
  if: failure()
  with:
    name: test-results
    path: test-results
```

```bash
gh run download                                  # grab the run's artifacts
npx playwright show-trace test-results/<test>/trace.zip
```

## Visual regression

Playwright ships `await expect(page).toHaveScreenshot()`, and screenshots
are a strong drift catcher — they see rendering regressions no assertion
thinks to check. [Visual
snapshots](https://quality.stereobooster.com/snapshot-testing.md#visual-snapshot) covers the
method itself: the tolerance, the pinned baseline environment, and what a pixel
diff cannot see. The "screenshots are too noisy" reputation is a
**determinism** problem, not a screenshot problem: a diff only flaps when
the page renders differently run to run. Pin the inputs and the noise
goes away:

- **Stub the network** so the UI renders fixed data — the biggest single
  source of flap.
- **Freeze time and randomness** — fix the clock and timezone
  (`page.clock`, a fixed `TZ`) and pin fake-data generation: a fixed
  `faker.seed(...)` plus `faker.setDefaultRefDate("2025-09-01T10:00:00")`,
  so "3 minutes ago", generated IDs, and ordering come out identical
  every run.
- **Normalize font rendering** — the cross-OS noise the tool gets blamed
  for is mostly font hinting and smoothing. A screenshot stylesheet wired
  via `toHaveScreenshot.stylePath` pins it; still snapshot in one
  environment (Playwright's Docker image) for the residual sub-pixel
  differences.
- **Move the mouse out of the way** before snapping
  (`await page.mouse.move(0, 0)`), so a stray hover state doesn't leak
  into the image.
- **Mask what you can't freeze** — for a genuinely dynamic region (a live
  chart, an ad slot, a third-party embed), pass
  `toHaveScreenshot({ mask: [locator] })` to paint over it so it can't
  move the diff.

```css
/* playwright/screenshot.css — applied to every toHaveScreenshot via stylePath */
* {
  font-synthesis: none;
  text-rendering: optimizeLegibility;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}
```

Wrap the snap-time defaults in one helper — pointer parked, full-page for a
`Page` and element-scoped for a `Locator` — and gate it behind a flag so
screenshots only run where the environment is pinned:

```ts
import { expect, type Page, type Locator } from "@playwright/test";

const isPage = (target: Page | Locator): target is Page =>
  (target as Page).mouse !== undefined;

export async function expectToHaveScreenshot(target: Page | Locator, name: string) {
  if (!CAPTURE_SCREENSHOTS) return;
  if (isPage(target)) {
    await target.mouse.move(0, 0); // park the pointer so hover doesn't leak
    await expect(target).toHaveScreenshot(name, { fullPage: true });
  } else {
    await expect(target).toHaveScreenshot(name);
  }
}
```

## Coverage

To get one coverage number from both your browser tests and your
in-process Vitest tests, merge them via [Monocart](https://quality.stereobooster.com/monocart.md); the
full working setup — the V8 fixture, the source-path remap, and the merge
step — is in that recipe.

## AI-assisted test generation

[Playwright MCP](https://github.com/microsoft/playwright-mcp) (Microsoft) lets an agent drive a
real browser over the Model Context Protocol — accessibility snapshots,
not screenshots. Add a `playwright` server running `npx @playwright/mcp@latest`
to `.vscode/mcp.json`. Give the agent the test-id and boundary conventions
up front, then walk it through the flow the test should cover.

## Referenced by

- [Coverage](https://quality.stereobooster.com/coverage.md) · Methods
- [Snapshot and approval testing](https://quality.stereobooster.com/snapshot-testing.md) · Methods
- [Testing GUI and mobile applications](https://quality.stereobooster.com/testing-gui-and-mobile-apps.md) · Methods
- [Monocart](https://quality.stereobooster.com/monocart.md) · Recipes
- [Recipes](https://quality.stereobooster.com/recipes.md) · Recipes
- [Agent experience](https://quality.stereobooster.com/ai-agent-experience.md) · AI
- [How AI fits into software quality](https://quality.stereobooster.com/ai.md) · AI

## References

[^luo2014]: Luo, Qingzhou, Farah Hariri, Lamyaa Eloussi, and Darko Marinov. 2014. "[An Empirical Analysis of Flaky Tests](https://mir.cs.illinois.edu/lamyaa/publications/fse14.pdf)." *Proceedings of the 22nd ACM SIGSOFT International Symposium on Foundations of Software Engineering (FSE '14)* (Hong Kong, China), 643–53. <https://doi.org/10.1145/2635868.2635920>.
