What Is Visual Regression Testing? Definition + Code

Visual regression testing compares screenshots of a UI against approved baselines to catch unintended visual changes. How diffs work, with tested code.

By Simon Chiu · Updated

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.

A pixelmatch diff image of example.com in which changed pixels are red: not only the first paragraph but every block of text below it is marked, because the wider paragraph wrapped differently and pushed the rest of the page down
pixelmatch output (threshold 0.1) for the change above: 24,233 of 756,000 pixels differ (3.2%). Captured with Puppeteer 24.43.1 and headless Chrome 148 on 2026-10-15, scaled to 600×315.

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:

  1. 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.
  2. 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.
  3. A tolerance. A ratio or count of changed pixels that still passes, to absorb rendering noise.
  4. 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.

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.

Frequently asked questions

How is visual regression testing different from functional testing?
A functional test checks behaviour: the button submits the form. A visual test checks appearance: the button is still blue, aligned and readable. A page can pass every functional test and still be visibly broken.
Why do visual tests fail when nothing changed?
Usually because the rendering environment changed: a different browser version, operating system, font set or device pixel ratio. Generate and compare baselines on the same pinned environment.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.