Playwright architecture, selector reliability, and advanced interaction patterns.

Visual Regression Testing

Functional assertions prove that a button exists, is enabled, and fires the right request; they say nothing about the button having turned invisible because a CSS refactor set its text colour to the same value as its background. Visual regression testing closes that gap by capturing a rendered screenshot, comparing it pixel by pixel against a committed baseline image, and failing the test when the difference exceeds a budget you define. Playwright ships this capability in the test runner itself through expect(page).toHaveScreenshot() and expect(locator).toHaveScreenshot(), with no third-party service required. The hard part is not calling the assertion — it is making the rendered page produce identical bytes on every run, on every machine, forever. This guide, part of Debugging & Test Observability, covers the comparison engine, the stabilization work that has to happen before the shutter opens, sensitivity tuning, baseline governance, and the failure modes that turn a promising visual suite into the noisiest job in your pipeline.

The four stages of a visual regression check A pipeline from page stabilization to capture, comparison against a stored baseline, and a pass or diff verdict. Baseline PNG committed to git 1. Stabilize animations off clock frozen 2. Capture toHaveScreenshot PNG bytes 3. Compare pixelmatch YIQ threshold 4. Verdict pass, or write diff.png Stages 2 to 4 are automatic; stage 1 is the engineering work.
Playwright automates capture and comparison; the stabilization work in stage one is what separates a trustworthy visual suite from a permanently red one.

How Playwright decides two screenshots differ

toHaveScreenshot() is not a simple byte equality check. Under the hood Playwright encodes the captured viewport as a PNG and hands it, along with the stored baseline, to the pixelmatch algorithm. Pixelmatch converts each pixel pair into the YIQ colour space — a model that weights luminance far more heavily than chroma, because the human eye does the same — and computes a perceptual distance between them. If that distance exceeds the threshold option (default 0.2, on a scale from 0 to 1), the pixel is counted as different and painted into a generated diff image. The count of different pixels is then measured against maxDiffPixels (an absolute number) and maxDiffPixelRatio (a fraction of the total image area). If neither budget is configured, any single differing pixel fails the assertion.

Just as important is what happens before the comparison. toHaveScreenshot() is a web-first assertion, so it retries inside the expect timeout in the same way toBeVisible() does, and the retry loop has an extra stage: Playwright captures a screenshot, captures another, and compares the two against each other. Only once two consecutive captures are identical does it consider the page settled and compare against the baseline. That built-in stability gate is why the assertion is dramatically more reliable than the older pattern of expect(await page.screenshot()).toMatchSnapshot(), which grabs a single frame with no retry and no settling logic. If the page never settles — a spinner that runs forever, a marquee, a live-updating counter — the assertion fails on the timeout rather than on a pixel budget, and the message points at the timeout instead of at a diff. Recognising which of those two failures you are looking at is the first branch in every visual debugging session, and it is a distinction the auto-waiting model described in Handling Dynamic Content will already feel familiar from.

The settle-then-compare retry loop A state machine where consecutive screenshots must match each other before the capture is compared with the stored baseline. Capture frame into memory Equals previous frame? Compare with baseline PNG Within budget assertion passes Over budget writes diff.png Never settles timeout error yes no — retry until timeout
Two consecutive captures must agree before the baseline comparison runs at all, which is why an unsettled page produces a timeout rather than a pixel diff.

Prerequisites

Three things must be in place before the first assertion is worth writing. First, a recent @playwright/testtoHaveScreenshot(), mask, stylePath, and snapshotPathTemplate all landed in the modern runner, and page.clock for time freezing arrived later still. Second, a decision about which environment is authoritative. Screenshots are a function of the operating system's font rasteriser, the GPU compositing path, and the browser build, so a baseline generated on a developer laptop will not match one generated on a Linux CI runner. Pick one environment — almost always the same container image your pipeline uses — and treat every other machine as a consumer of those images. Third, a deterministic application state: seeded data, mocked network responses, and a fixed clock, so that the pixels are a pure function of your code rather than of the day, the database, or a third-party advertisement.

Pin the viewport explicitly while you are at it. A screenshot's dimensions are derived from the viewport, so an inherited or defaulted viewport setting means the day someone changes it, every baseline in the suite becomes invalid at once. Declare the viewport in the project that owns the visual tests, treat it as part of the contract, and change it only with a deliberate baseline regeneration. The same applies to deviceScaleFactor: a project emulating a mobile device at 3x produces images three times the linear size of a 1x desktop project, which is a legitimate configuration but must be an intentional one.

It also pays to think about scope before writing tests. Full-page screenshots of every route sound thorough but produce enormous images where a one-pixel shift in a header invalidates every baseline you own. Component-level and section-level screenshots localise failures, review faster, and update in isolation. A practical suite mixes both: a small number of full-page shots for the routes where overall layout matters, and a broader set of element-scoped shots for shared components such as navigation bars, cards, modals, and empty states.

Your first screenshot assertion

The assertion takes a name, which becomes the filename of the baseline. On the first run no baseline exists, so Playwright writes the captured image to disk and fails the test with Error: A snapshot doesn't exist at .../example.spec.ts-snapshots/pricing-chromium-linux.png, writing actual. That failure is deliberate: it forces you to look at the generated image and consciously commit it as the intended appearance rather than silently accepting whatever the first run produced.

import { test, expect } from '@playwright/test';

test('pricing page matches its baseline', async ({ page }) => {
  await page.goto('/pricing');

  // Gate the screenshot on a functional assertion first. Without this the
  // capture can start before the route has painted its main content, and the
  // settle loop will happily agree that two blank frames are identical.
  await expect(page.getByRole('heading', { name: 'Plans' })).toBeVisible();

  // The name is the baseline filename; keep it stable, it is the identity of
  // the image on disk. Playwright disables CSS animations and hides the text
  // caret by default for this assertion.
  await expect(page).toHaveScreenshot('pricing.png');
});

Anchoring the capture behind a role-based assertion matters more than it looks. The settle loop compares consecutive frames, and two identical frames of a skeleton loader are still identical — the loop will conclude the page is stable and compare a skeleton against your real baseline. A functional gate on content that only exists after render eliminates that whole family of failure.

Scoping the snapshot: page, element, region

toHaveScreenshot() exists on both Page and Locator. The locator form clips to the element's bounding box, scrolls it into view first, and captures nothing else, which makes it the right default for design-system work. The page form captures the viewport, or the entire scrollable document with fullPage: true. There is also clip, which takes explicit coordinates — useful when you need a fixed region that does not correspond to a single element, such as the top 200 pixels of a page across several routes.

import { test, expect } from '@playwright/test';

test.describe('checkout visuals', () => {
  test('summary card and full page', async ({ page }) => {
    await page.goto('/checkout');

    // Element-scoped: the smallest useful image, and the one that survives
    // unrelated layout changes elsewhere on the route.
    const summary = page.getByRole('region', { name: 'Order summary' });
    await expect(summary).toHaveScreenshot('order-summary.png');

    // Region-scoped: an explicit rectangle, independent of the DOM tree.
    await expect(page).toHaveScreenshot('checkout-header.png', {
      clip: { x: 0, y: 0, width: 1280, height: 220 },
    });

    // Document-scoped: expands the viewport to the full scroll height. Large
    // images, and every diff below the fold invalidates the whole baseline.
    await expect(page).toHaveScreenshot('checkout-full.png', { fullPage: true });
  });
});

Two details bite people here. A locator screenshot fails with Error: element is not visible if the element is display-none at capture time, so the same functional gate applies. And fullPage interacts badly with position: sticky headers and lazy-loaded images: the browser scrolls the document to stitch the image, sticky elements can be painted repeatedly, and images below the fold may not have loaded when their strip is captured. If you need full-page shots of a lazily-loading route, scroll to the bottom and back to the top before asserting, or disable the lazy-loading behaviour in test builds.

Making the page deterministic before the shutter opens

Everything that changes between two runs is a false positive waiting to be filed as a bug. There are four common sources, and each has a mechanical fix.

Time. Relative timestamps ("3 minutes ago"), date pickers defaulting to today, and copyright years all move. Freeze the browser clock with page.clock.setFixedTime() before navigation, and every Date the application constructs returns the same instant.

Network data. A live API returns different records each run. Route the endpoints your screenshot depends on and fulfil them with fixed payloads, exactly as described in Mocking API Responses with Playwright. This is also what makes the suite fast enough to run on every commit.

Animation and motion. Playwright sets animations: 'disabled' for toHaveScreenshot() by default, which finishes CSS transitions and animations and rewinds infinite ones to their first frame. That covers declarative motion, but not animation driven by JavaScript on requestAnimationFrame, and not third-party embeds. For those, inject a stylesheet with stylePath that neutralises the offending elements.

Fonts. A web font that has not finished loading renders as a fallback face with different metrics, which shifts every line of text. Await document.fonts.ready before capture, and self-host fonts in the test environment so that a slow CDN cannot change your layout.

import { test, expect } from '@playwright/test';

test('dashboard renders identically on every run', async ({ page }) => {
  // 1. Freeze time before navigation so the first render already sees it.
  await page.clock.setFixedTime(new Date('2026-07-24T09:30:00Z'));

  // 2. Pin the data. Fixed numbers mean fixed pixel widths for every label.
  await page.route('**/api/metrics', async route => {
    await route.fulfill({ json: { visitors: 1234, revenue: 5678, trend: 'up' } });
  });

  await page.goto('/dashboard');
  await expect(page.getByRole('heading', { name: 'This week' })).toBeVisible();

  // 3. Wait for web fonts; a fallback face changes every text advance width.
  await page.evaluate(() => document.fonts.ready);

  await expect(page).toHaveScreenshot('dashboard.png', {
    // 4. Neutralise JS-driven motion and third-party embeds that the built-in
    //    animation freeze cannot reach. The file is plain CSS.
    stylePath: './tests/visual/stabilize.css',
    // Explicit even though both are the defaults, because they document intent.
    animations: 'disabled',
    caret: 'hide',
    // 'css' captures in CSS pixels, so a Retina laptop and a 1x CI runner
    // produce images of the same dimensions.
    scale: 'css',
  });
});

The stylePath file is ordinary CSS injected just before capture. A useful starting point sets * { animation: none !important; transition: none !important; }, hides scrollbars with ::-webkit-scrollbar { display: none; }, and applies visibility: hidden to any embed you cannot control. Because it is applied only for the screenshot, it does not affect the functional assertions in the same test.

Controlling comparison sensitivity

Three options govern how much difference is tolerated, and confusing them is the most common configuration error in visual suites. threshold decides whether an individual pixel counts as different at all; it is a perceptual distance, so raising it makes the comparison blind to subtle colour shifts everywhere in the image. maxDiffPixels and maxDiffPixelRatio decide how many differing pixels the image as a whole may contain before the assertion fails; they are area budgets and do not affect per-pixel sensitivity.

Comparison of the three sensitivity options A matrix listing threshold, maxDiffPixels and maxDiffPixelRatio with what each measures and when to reach for it. Option What it measures Reach for it when threshold default 0.2 One pixel pair, YIQ distance Anti-aliasing speckle appears along every glyph and curved edge maxDiffPixels absolute count Whole image, pixel total Images are a known fixed size and you want one shared budget maxDiffPixelRatio fraction 0 to 1 Whole image, share of area Snapshots range from small cards to tall full-page captures
Per-pixel sensitivity and whole-image budget are independent controls; raising the wrong one hides real regressions while leaving the noise in place.

Set the defaults once in playwright.config.ts rather than per assertion, so the whole suite shares a policy and individual tests only override it when they have a documented reason. Configuration lives beside the rest of your runner setup covered in Playwright Config & Fixtures.

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  // Baselines land in a readable tree instead of beside every spec file.
  // {projectName} and {platform} keep browser and OS variants apart.
  snapshotPathTemplate:
    '{testDir}/__screenshots__/{projectName}-{platform}/{testFileName}/{arg}{ext}',
  expect: {
    // Screenshot assertions retry, so they need a longer budget than the
    // default 5s expect timeout allows for a heavy full-page capture.
    timeout: 15_000,
    toHaveScreenshot: {
      // Keep per-pixel sensitivity at the default; widen the area budget so a
      // handful of anti-aliased edge pixels does not fail the build.
      threshold: 0.2,
      maxDiffPixelRatio: 0.01,
      animations: 'disabled',
      scale: 'css',
    },
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    // A fixed viewport is mandatory: change it and every baseline is invalid.
    { name: 'chromium-mobile', use: { ...devices['Pixel 7'] } },
  ],
});

Note the expect.timeout bump. Screenshot assertions do real work on every retry — capture, encode, compare — and a full-page capture of a long route can take a second on its own. Leaving the default 5-second budget in place is a common cause of Timed out 5000ms waiting for expect(page).toHaveScreenshot(expected) on CI machines that are slower than a developer laptop. Choosing the numbers themselves is a subject of its own, worked through in Tuning Screenshot Comparison Thresholds, which walks from a strict zero-tolerance baseline to a calibrated budget derived from measured run-to-run noise.

Hiding what you cannot stabilize

Some pixels will never be deterministic: an embedded map tile, a video poster frame served by a CDN, an advertisement slot, a live "users online" counter, a signature canvas. The mask option accepts an array of locators and paints each matched element with a solid overlay before the comparison, so the region contributes identical pixels to both baseline and capture regardless of what it actually rendered. The default overlay colour is a bright magenta chosen to be visually obvious in a review, and maskColor overrides it when magenta collides with your palette.

import { test, expect } from '@playwright/test';

test('activity feed ignores volatile regions', async ({ page }) => {
  await page.goto('/feed');
  await expect(page.getByRole('list', { name: 'Activity' })).toBeVisible();

  await expect(page).toHaveScreenshot('feed.png', {
    // Each locator may match several elements; all matches are covered.
    mask: [
      page.getByTestId('relative-time'),   // "3 minutes ago" ticks upward
      page.getByRole('img', { name: 'Avatar' }), // gravatar hashes vary by seed
      page.locator('iframe[title="Advertisement"]'),
    ],
    // Solid colour, no hex needed; pick one absent from your design system.
    maskColor: 'rgb(0, 128, 0)',
  });
});

Masking is a scalpel, not a bandage. Every masked region is a region you have stopped testing, so mask the smallest element that contains the volatility — the timestamp <span>, not the whole card — and prefer stabilising the data over hiding it whenever a route or a fixed clock can do the job. The trade-offs, including how masking interacts with layout shift when the masked element changes size, are covered in Masking Dynamic Regions in Snapshots, which shows how to keep a mask from silently swallowing a genuine regression.

Where baselines live and how they are updated

By default Playwright writes baselines to a directory named after the spec file — example.spec.ts-snapshots/ — with a filename assembled from the snapshot name, the project name, and the platform: pricing-chromium-linux.png. The snapshotPathTemplate option rewrites that layout using the tokens {testDir}, {testFileDir}, {testFileName}, {testFilePath}, {projectName}, {platform}, {arg} and {ext}. Two rules should drive your template: keep {projectName} in the path if you run more than one browser or viewport, and keep {platform} in the path unless you generate every baseline in one container — otherwise a macOS developer and a Linux runner will fight over the same file forever.

Updating is done with --update-snapshots (-u). Modern runner versions accept a mode: --update-snapshots=missing writes only baselines that do not yet exist and is safe to leave enabled for new tests, changed rewrites those that failed comparison, all rewrites everything, and none disables writing entirely. The dangerous habit is running a bare -u after any red build, which rewrites the evidence of a real regression into the new expected state. Treat baseline updates as a deliberate, reviewed change: run the update, open the resulting image diff in the pull request, and have a human confirm the new appearance is intended.

The review itself is where visual testing earns its keep or fails outright. An engineer looking at a diff has to answer one question — did we mean to do this? — and the answer depends on being able to see the change clearly. That argues for small images with obvious semantics, for snapshot names that read as descriptions rather than as identifiers, and for keeping baseline updates in commits that touch nothing else. A pull request containing forty regenerated PNGs alongside a refactor gets approved without inspection every time, which converts the whole suite into an expensive way of writing image files.

Cross-platform baseline management deserves its own strategy, because it is where most teams either accept a permanently noisy suite or accidentally restrict visual testing to one machine. Managing Screenshot Baselines Across Platforms covers container-generated baselines, per-platform path templates, and a bot-driven update workflow that keeps images out of manual git surgery.

Failure modes and debugging

A failed visual assertion prints three paths — Expected, Received, and Diff — under a header of the form Error: Screenshot comparison failed: followed by a line such as 12480 pixels (ratio 0.02 of all image pixels) are different. Those numbers are the diagnostic: a handful of pixels is noise, a few percent is a component change, and half the image is a layout break. The HTML reporter renders all three images with a slider and an onion-skin view, which is far easier to read than opening PNGs by hand; wiring that reporter up is covered in Reporters & Test Artifacts.

Reading the shape of a screenshot diff A decision tree mapping four visual patterns in a diff image to their usual root causes and fixes. Open diff.png Text edges only One boxed region Everything shifted Scattered speckle Font rasteriser pin the container Volatile content mask or mock it Viewport or bar check scrollbars Anti-aliasing widen the budget
The geometry of the diff image identifies the root cause faster than the pixel count does: read the shape first, then the number.

Text-shaped noise everywhere. Every glyph outlined in the diff means the font rasteriser changed, not your CSS. The usual cause is a baseline generated on macOS being compared on Linux, or a container image bump that pulled in different fontconfig settings. The fix is environmental, not numerical: generate baselines in the same image the pipeline runs, as described in Dockerizing Playwright for Headless CI.

Diff dimensions do not match. The error reads Expected an image 1280px by 3204px, received 1280px by 3196px. Pixelmatch cannot compare images of different sizes, so this always fails regardless of budget. The cause is a content-height change on a fullPage capture, a viewport mismatch between config and baseline, or a deviceScaleFactor difference. Using scale: 'css' removes the last of those by normalising Retina captures to CSS pixels.

Passes locally, fails in CI. Headed and headless rendering can differ subtly, and CI machines are slower, which exposes race conditions that never appear on a fast laptop. Run the failing spec inside the CI container locally before touching thresholds. If the difference is timing rather than rendering, it is a stability problem, and the diagnosis techniques in Flaky Test Management apply unchanged.

Timeout instead of a diff. Timed out 15000ms waiting for expect(page).toHaveScreenshot(expected) with no diff image means the settle loop never saw two identical frames. Something on the page is still moving: an infinite CSS animation on a non-transformed property, a JavaScript ticker, a lazy-loading image, or a carousel. Record a trace and step through the DOM snapshots to find the moving element; the workflow is covered in Trace Viewer & Debugging.

A masked region hides a real change. When a mask covers more than the volatile element — a whole card instead of its timestamp — a genuine regression inside that card produces no diff at all, and the test stays green through a broken layout. The symptom is a bug reported by a human on a screen the suite claims to cover. Audit masks periodically by opening the actual capture from a passing run and checking how much of the image is painted over; if the overlay covers a meaningful share of the component, the mask is too wide.

Everything fails after a dependency bump. Browser engine updates change rendering. A Playwright version bump ships new browser builds, so a mass baseline update after upgrading is expected and legitimate — but it must be its own commit, reviewed on its own, never mixed with product changes.

CI/CD considerations

Visual tests amplify every weakness in a pipeline, because they are the only tests that depend on the exact rendering stack of the machine. Four practices keep them tractable.

Pin the environment. Run visual projects only inside a version-pinned Playwright container image, and generate every baseline in that same image. Pinning the image tag rather than tracking a floating one means a rendering change arrives when you choose to upgrade, not on a random Tuesday. The broader pipeline shape — browser caching, sharding, artifact publication — is covered in CI/CD Integration.

Upload the diffs. A failed visual test is unreadable from a log line. Publish the test-results/ directory and the HTML report as build artifacts so reviewers can open the expected, actual, and diff images from the pull request. This is the same artifact plumbing described in Capturing Screenshots and Video on Test Failure.

Never update baselines from a normal CI run. Set updateSnapshots: 'none' for pipeline runs so a misconfigured job cannot quietly rewrite expectations. Provide a separate, manually-triggered workflow that runs --update-snapshots=changed in the pinned container and opens a pull request containing only the changed images. That pull request is the review surface for intended visual change.

Budget the runtime. Every screenshot assertion encodes a PNG and runs a pixel comparison, and the settle loop means at least two captures per assertion. On a large route with a fullPage capture that is measurably slower than any functional assertion in the suite, and the cost scales with image area rather than with test count. Two habits keep the job fast: prefer element-scoped captures, which shrink both the encode and the compare, and run the visual project with a worker count tuned to the runner's available memory, since several simultaneous full-page encodes on a small container is a reliable way to earn an out-of-memory kill.

Separate the project. Give visual tests their own project or tag so they can be run, retried, and skipped independently of the functional suite. They are slower and their failures need human judgement, which is a different operational shape from a functional regression. Running them against a fixed viewport in a single browser first, and only expanding to the browser matrix described in Cross-Browser Execution once the suite is stable, avoids multiplying baseline count before the workflow is proven.

A reusable fixture is the cleanest way to apply the stabilisation policy everywhere without repeating it in each test:

import { test as base, expect, type Page } from '@playwright/test';

// A fixture that hands back a page already frozen in time and stripped of
// motion, so individual specs contain only the assertion that matters.
export const test = base.extend<{ visualPage: Page }>({
  visualPage: async ({ page }, use) => {
    await page.clock.setFixedTime(new Date('2026-07-24T09:00:00Z'));
    await page.addInitScript(() => {
      // Neutralise randomness before any application code runs.
      Math.random = () => 0.42;
    });
    await page.emulateMedia({ reducedMotion: 'reduce' });
    await use(page);
  },
});

test('empty state looks right', async ({ visualPage }) => {
  await visualPage.goto('/projects');
  await expect(visualPage.getByText('No projects yet')).toBeVisible();
  await expect(visualPage).toHaveScreenshot('projects-empty.png');
});

export { expect };

emulateMedia({ reducedMotion: 'reduce' }) is worth calling out: any application that honours prefers-reduced-motion will skip its own animations entirely, which is stronger than freezing them after the fact. Fixture-scoped setup like this also keeps state isolated per test, following the model in Browser Contexts & Isolation.

Deep dives from here

Three areas repay detailed treatment once the basic assertion is working:

Frequently Asked Questions

Should screenshot baselines be committed to the repository?

Yes. A baseline is the specification of what the interface should look like, so it belongs under version control next to the code that produces it, and its change history is the record of every intentional design change. The practical concern is repository size: PNGs are binary, they do not delta-compress well, and a large suite regenerated often can add tens of megabytes over a year. Keep images small by preferring element-scoped captures over full-page ones, and if the suite grows past a few hundred images consider storing them with Git LFS so clones stay fast.

Why does the same test pass on my machine and fail on CI?

Because a screenshot is a product of the whole rendering stack, not just your code. Font packages, font hinting configuration, GPU compositing, device pixel ratio, and the browser build all differ between a developer laptop and a Linux runner, and any one of them shifts pixels. The reliable fix is to make one environment authoritative — the version-pinned container the pipeline uses — and generate every baseline there, running the visual project locally through the same image when you need to reproduce a failure.

Is toMatchSnapshot still useful for images?

Not for page captures. toMatchSnapshot() compares a value you have already produced, so it takes a single frame with no retry and no settling logic, which makes it far more prone to catching a page mid-render. toHaveScreenshot() is the purpose-built assertion: it retries within the expect timeout, waits for two consecutive identical frames, and understands the screenshot-specific options. Keep toMatchSnapshot() for non-image values such as serialised API payloads or generated text files.

How many visual tests should a suite have?

Fewer than you would expect, chosen for leverage rather than coverage. Shared components — navigation, buttons, form controls, cards, modals, empty and error states — catch the overwhelming majority of unintended visual change because a regression in them appears on every route. Add full-page captures only for the handful of routes where overall composition is itself the product. A suite of thirty well-chosen images that engineers trust is worth far more than three hundred that get updated in bulk without anyone looking at them.

Back to overview