Playwright
Advanced 17 min read Playwright

Visual Regression Testing

toHaveScreenshot compares a live screenshot to a committed golden image pixel by pixel. Visual regression catches unintended UI changes across components, pages and full flows — but only with strict controls on fonts, animation and anti-aliasing.

toHaveScreenshotgolden filessnapshot testingpixel diffmaxDiffPixelRatioanimationsupdate snapshots
Introduction

What is this?

Visual regression testing captures screenshots and compares them to baseline 'golden' images stored in the repo. Playwright's toHaveScreenshot re-takes the shot, compares it pixel-by-pixel with configurable tolerance, and fails the test with a human-readable diff image when they diverge. Golden images are updated intentionally with --update-snapshots.

Motivation

Why do you need it?

  • Functional assertions miss styling bugs: a shifted layout, a broken icon or a regressed font passes every logic test.
  • Golden images give designers and reviewers a commit-reviewed record of what each screen looked like.
  • Pixel diffs surface unintentional changes across browsers, breakpoints and themes in seconds.
  • toHaveScreenshot retries until the image stabilizes (animations settled), reducing false positives.
  • The diff images produced on failure make triage fast and visual, not a wall of assertion text.
Think about it

Mental model

Imagine

Comparing a signed print to the gallery's master print

Think of

toHaveScreenshot vs the golden image

The gallery measures the print against the master under controlled light. Playwright compares the rendered page against the committed golden under controlled fonts, viewport and animations — and flags any deviation with a marked-up diff.

Mechanics

How it works

  • Run tests with toHaveScreenshot first time — Playwright captures the page and writes a golden PNG into the snapshots directory.
  • On later runs it re-captures the same viewport and compares pixel colors with the golden image.
  • Differences beyond the tolerance (maxDiffPixelRatio / maxDiffPixels) fail the test and produce an actual-vs-expected-vs-diff image set.
  • Playwright waits for two consecutive frames of stability before capturing, which drops most animation jitter.
  • Running npx playwright test --update-snapshots replaces goldens after an intentional, reviewed change.

The visual comparison loop behind toHaveScreenshot.

Navigate to screen
Capture screenshot
Golden exists?
Compare pixels
Diff within limit?
Pass or emit diff
Reference

Key commands

npx playwright test --update-snapshots

Re-capture every golden image, replacing the committed baselines.

  3 snapshot(s) updated.
await expect(page).toHaveScreenshot()

Compare the full page screenshot to the golden image.

await expect(locator).toHaveScreenshot('name.png')

Compare a single element's screenshot to a named golden.

npx playwright test --update-snapshots --grep 'pricing'

Update only the snapshots matching a filter, so unrelated goldens stay untouched.

maxDiffPixelRatio: 0.02

Tolerate up to 2% of pixels differing before the test fails.

animations: 'disabled'

Pause CSS animations before capturing so the frame is deterministic.

Hands-on

Examples

A visual regression check for a pricing card

A visual regression check for a pricing card
import { test, expect } from "@playwright/test";
test.use({
viewport: { width: 1280, height: 800 },
colorScheme: "light",
});
test("pricing card matches the golden image", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("https://example.com/pricing");
const card = page.getByTestId("pricing-card");
await expect(card).toBeVisible();
await expect(card).toHaveScreenshot("pricing-card.png", {
maxDiffPixelRatio: 0.02,
animations: "disabled",
});
});

test.use pins the viewport and color scheme so the golden image is captured under reproducible conditions. emulateMedia disables animations, and the screenshot options disable animations again and allow a 2% pixel tolerance for font smoothing and subtle anti-aliasing differences. The golden is compared and stored under the test name's snapshots folder.

Real world

In production

  • Component screenshot suites (buttons, cards, nav) catch regressions before they reach a page-level test.
  • Cross-browser goldens per project catch WebKit vs Chromium rendering drift that logic tests miss.
  • Theme and dark-mode goldens verify every screen against both palettes on every commit.
  • PR screenshots generated on the diff images give designers a visual review gate without running the app.
Guidelines

Best practices

  • Pin viewport, device scale factor, color scheme and timezone in test.use so goldens are reproducible.
  • Disable animations (reducedMotion/animations: 'disabled') and accept font loading before capturing.
  • Keep maxDiffPixelRatio tight (0–2%) and raise it deliberately for known anti-aliasing variance, never to hide bugs.
  • Review snapshot diffs in every PR; update goldens only after confirming the change is intentional.
  • Scope screenshots to elements or regions instead of full pages to minimize rendering variance.
Watch out

Common mistakes

  • Running --update-snapshots after any failure, blindly overwriting goldens that actually caught a real regression.
  • Capturing full-page shots of pages with dynamic ads, dates or avatars, producing permanent flakiness.
  • Forgetting to pin the viewport, so local vs CI rendering sizes diverge and every run diffs.
  • Comparing against goldens generated with a different browser version, causing systemic pixel drift.
Root cause analysis

Common issues

Error
Screenshot comparison failed: 3.5% of pixels differ
Root cause

Font rendering, shadows or anti-aliasing vary between the baseline machine and the current one.

Fix

Generate goldens on the same OS/browser used in CI, disable subpixel variations via forced fonts, and adjust maxDiffPixelRatio deliberately.

Error
Snapshot 'pricing-card' is missing, wrote it to disk
Root cause

This is the first run or the golden was deleted.

Fix

That's expected on the first run — review and commit the newly written golden image.

Error
Timed out waiting for screenshot to stabilize
Root cause

An endless animation, carousel or live clock keeps the page from being frame-stable.

Fix

Mock the animated widget, freeze time with clock.setFixedTime or page.clock, and keep animations disabled in config.

When things break

Debugging tips

  • Open the three generated images on failure: expected, actual and the diff with changed regions highlighted.
  • If the whole image shifts by a constant amount, suspect viewport or scroll position rather than a UI bug.
  • Zoom into the diff PNG to see whether changes are text (fonts/OS rendering) or layout (real regressions).
  • Generate goldens in CI (not just locally) so OS font rendering matches production's expected baseline.
Interviews

Interview checkpoint

Practice

Hands-on lab

Golden Images for a Pricing Page
45 min · advanced
In progress
Objectives
  • Capture a golden image for a pricing card
  • Introduce a deliberate UI change and observe the pixel diff
  • Use diff images to triage a failed comparison
  • Update goldens intentionally with a filtered update

Golden Images for a Pricing Page

45 min · advanced

0/5 steps
0%
sandbox · Linux sandbox
$
Try these commands
Test yourself

Quick quiz

0/3
1

Which option limits how different a screenshot can be before failing?

2

You should run --update-snapshots to confirm every unexpected screenshot failure is a real bug.

3

A golden was captured on a Mac with Retina rendering, and CI on Linux diffs on every run. What is the root cause?

Quick reference

Cheat sheet

Screenshot comparison
await expect(page).toHaveScreenshot()Full-page golden
await expect(locator).toHaveScreenshot('card.png')Element golden
--update-snapshotsRegenerate goldens
--update-snapshots --grep 'pricing'Regenerate a subset
maxDiffPixelRatio: 0.02Tolerate 2% pixel drift
animations: 'disabled'Freeze CSS animations
Deterministic captures
test.use({ viewport, colorScheme })Pin rendering inputs
page.emulateMedia({ reducedMotion })Disable motion
page.clock.setFixedTime(date)Freeze clocks/widgets
page.route('**/ads/*', abort)Strip dynamic content
Go deeper

Official documentation