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)1.
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, 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¶
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.
// 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¶
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:
// 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 });
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.
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.
# .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(), and screenshots
are a strong drift catcher — they see rendering regressions no assertion
thinks to check. Visual
snapshots 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 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 — 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:
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; 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 (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
- Agent experience · AI
- How AI fits into software quality · AI
References¶
-
Luo, Qingzhou, Farah Hariri, Lamyaa Eloussi, and Darko Marinov. 2014. "An Empirical Analysis of Flaky Tests." 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. ↩