Visual regression testing is the practice of taking screenshots of an interface and comparing them, pixel by pixel or perceptually, with approved baseline images, so that unintended visual changes fail a build before users see them.
It answers the question unit tests cannot: does it still look right? This entry is part of the screenshot glossary. For tooling and workflows, see the visual regression testing use case.
What it means in plain terms
You capture each important page or component once and a person approves the images. Those are the baselines. On every later change, the test captures the same pages under the same conditions and compares. If too many pixels differ, the test fails and shows you a diff image. Either the change is a bug, or it was intended and you approve the new image as the next baseline.
The method catches what code review misses: a CSS refactor that shifts a footer, a dependency upgrade that changes a font, a z-index that hides a button.
What a diff looks like
We captured example.com at 1200×630, then captured it again with one injected rule: p:first-of-type { letter-spacing: 0.5px; }. Half a pixel of spacing on one paragraph.
The lesson is in the red: the edit touched one paragraph, but the diff covers everything below it. A small change near the top of a page moves every pixel underneath. That is why full-page diffs are noisy and why mature suites capture components or regions as well as whole pages.
How it works technically
A visual test has four parts:
- A deterministic capture. Same browser version, viewport, device scale factor, fonts, colour scheme and data. Disable animations and wait for the page to be ready.
- A comparison. A pixel diff counts pixels whose colour distance exceeds a threshold. Perceptual methods (for example SSIM) score structure instead and ignore tiny antialiasing changes.
- A tolerance. A ratio or count of changed pixels that still passes, to absorb rendering noise.
- A review step. A person approves intended changes, which become the new baselines in version control.
The capture is where most flakiness comes from. Animations, carousels, timestamps, ads and lazy images all make two captures of an unchanged page differ.
Playwright Test
Playwright ships the whole loop: capture, compare, store baselines, write diff images on failure.
import { test, expect } from '@playwright/test'; test('home page looks the same', async ({ page }) => { await page.setViewportSize({ width: 1200, height: 630 }); await page.goto('https://example.com'); await expect(page).toHaveScreenshot('home.png', { maxDiffPixelRatio: 0.001, // allow 0.1% of pixels to differ animations: 'disabled', }); });
Run npx playwright test --update-snapshots once to write the baseline, then npx playwright test on every change. Baseline file names include the browser and platform, because Chromium on macOS and Linux render text differently. Generate baselines in the same environment as CI, often a Docker image.
Puppeteer with pixelmatch
Puppeteer has no built-in comparison, so pair it with pixelmatch. This is the script that produced the diff above:
import puppeteer from 'puppeteer'; import pixelmatch from 'pixelmatch'; import { PNG } from 'pngjs'; import { writeFile } from 'node:fs/promises'; const url = process.argv[2] ?? 'https://example.com'; async function capture(page, css = '') { await page.goto(url, { waitUntil: 'load' }); if (css) await page.addStyleTag({ content: css }); await page.evaluate(() => document.fonts.ready); return PNG.sync.read(await page.screenshot({ type: 'png' })); } const browser = await puppeteer.launch(); try { const page = await browser.newPage(); await page.setViewport({ width: 1200, height: 630 }); const baseline = await capture(page); // Simulate a regression: a CSS change that widens the letter spacing of the first paragraph. const current = await capture(page, 'p:first-of-type { letter-spacing: 0.5px; }'); const { width, height } = baseline; const diff = new PNG({ width, height }); const changed = pixelmatch(baseline.data, current.data, diff.data, width, height, { threshold: 0.1 }); await writeFile('diff.png', PNG.sync.write(diff)); const ratio = changed / (width * height); console.log(`${changed} px changed (${(ratio * 100).toFixed(3)}%)`); if (ratio > 0.001) process.exitCode = 1; // fail the build above 0.1% } finally { await browser.close(); }
In a real suite the baseline comes from a file in the repository, not from a second capture.
Tested on 2026-10-15 with Node 22.18.0, [email protected] (run with headless: 'shell'), [email protected], [email protected] and @playwright/[email protected] (Chromium 156). The Playwright test passed twice in a row against its own baseline; the Puppeteer script exited with code 1 and printed 24233 px changed (3.205%).
Using RenderScreenshot as the capture step
An API capture removes one variable: every run uses the same hosted browser and fonts, wherever your CI runs. Request PNG (lossless, so compression does not create false differences), pin the viewport and scale, and hide moving parts:
curl -X POST https://api.renderscreenshot.com/v1/screenshot \ -H "Authorization: Bearer $RS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://staging.example.com/pricing", "viewport": { "width": 1280, "height": 800, "scale": 1 }, "output": { "format": "png" }, "browser": { "reduced_motion": true }, "wait": { "until": "networkidle" }, "cache": { "refresh": true } }' \ --output current.png
Then feed current.png and your stored baseline into the pixelmatch step above. cache.refresh makes sure you diff a fresh render, not a cached one. This request was not run for this page.
Common mistakes
- Comparing JPEG or WebP captures. Lossy compression adds differences of its own.
- Baselines from a laptop, tests in CI. Different fonts and OS rendering fail every test.
- A zero tolerance. Antialiasing noise makes the suite flaky, and people start ignoring it.
- Full-page captures only. One shift near the top marks everything below, as the diff above shows.
- Dynamic content left in. Mask or hide dates, avatars, ads and cookie banners.
Related terms
- Pixel diff
- Wait strategies
- prefers-color-scheme: test each theme against its own baseline.
- Device pixel ratio
For a full workflow with review and approval, read the visual regression testing guide. To capture baselines from a stable hosted browser, get a free API key.