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¶
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.
Recommended playwright.config.ts baseline¶
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.
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
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 fixedTZ) and pin fake-data generation: a fixedfaker.seed(...)plusfaker.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.stylePathpins 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¶
- Coverage · Methods
- Snapshot and approval testing · Methods
- Testing GUI and mobile applications · Methods
- Monocart · Recipes
- Recipes · Recipes
- How AI fits into software quality · AI