Skip to content

Software Quality

Playwright

Playwright drives a real browser through the actions a user performs: Chromium, Firefox, or WebKit. One test therefore exercises routing, rendering, network calls, and client state together. That reach comes at a cost: a browser test is slow, and every action waits on an asynchronous result, which is the main measured source of flaky tests (testing GUI and mobile applications).

This page targets Playwright ≥ 1.50 with TypeScript. The method behind it is example tests run at the whole-system level, serving the functionality dimension.

Setup

npm init playwright@latest

The generator scaffolds playwright.config.ts, an optional GitHub Actions workflow, and a test folder, which this page calls playwright/. Its defaults are a reasonable starting point, and the baseline below restates them alongside the settings it adds.

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 cap on the whole run; a per-test bound and a per-assertion one instead.
  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}",

  // Debug artifacts: the trace on the first retry, media on failure.
  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 what makes a Playwright suite expensive to maintain. 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.

// 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.

// 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:

// 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 produces valid data from a Zod schema (the same schemas the TypeScript recipe defines), and faker 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 when you want one set of handlers shared across Playwright, Vitest, and Storybook.

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

page.route is how you choose the boundary a test runs at. 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:

// 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:

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:

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

Traces

A trace records the run for replay afterwards — DOM snapshots, network requests, console output, screenshots, and the test code at each step.

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 how you debug a CI-only failure: upload test-results/ as an artifact on failure, then pull it down and replay it on your machine.

# .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
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(), which catches rendering regressions no assertion was written to check. The comparison passes within a tolerance rather than on exact bytes; visual snapshots covers how to set it. A diff flaps only when the page renders differently run to run, so screenshot noise is a determinism problem. Pin the inputs and what is left is sub-pixel:

  • Stub the network so the UI renders fixed data rather than whatever the backend held at that moment.
  • 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.
/* 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, gated behind a flag so screenshots only run where the environment is pinned: the pointer parked, full-page for a Page and element-scoped for a Locator.

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.

AI-assisted test generation

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