Frameworks2 min read

Puppeteer visual regression testing

Puppeteer captures but does not compare. This builds a working visual regression setup with pixelmatch, covers the determinism you have to add yourself, and is honest about when to stop and use a tool.

VisualMax EngineeringProduct team

Puppeteer takes excellent screenshots and has no opinion whatsoever about comparing them. There is no baseline concept, no diffing, no approval step. If you want visual regression testing in Puppeteer you are assembling it from parts — which is entirely reasonable, as long as you know that going in.

The parts you need

  1. Capture — Puppeteer's page.screenshot().
  2. Decode — pngjs, to turn PNG buffers into pixel arrays.
  3. Compare — pixelmatch (ISC, 6.9k stars), or ODiff if throughput matters.
  4. Store — a baseline directory, or object storage keyed to commits.
  5. Report — somewhere a human can look at the diff.
bash
npm i -D puppeteer pixelmatch pngjs

A working implementation

js
import { mkdirSync, existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import puppeteer from "puppeteer";
import { PNG } from "pngjs";
import pixelmatch from "pixelmatch";

const BASELINE_DIR = "visual/baseline";
const OUTPUT_DIR = "visual/output";
const UPDATE = process.env.UPDATE_SNAPSHOTS === "1";

export async function checkPage(page, name, { maxDiffRatio = 0.01 } = {}) {
  mkdirSync(BASELINE_DIR, { recursive: true });
  mkdirSync(OUTPUT_DIR, { recursive: true });

  const baselinePath = join(BASELINE_DIR, `${name}.png`);
  const current = await page.screenshot({ fullPage: true });

  // First run, or an explicit re-record: accept and move on.
  if (UPDATE || !existsSync(baselinePath)) {
    writeFileSync(baselinePath, current);
    return { status: "recorded", name };
  }

  const baseline = PNG.sync.read(readFileSync(baselinePath));
  const actual = PNG.sync.read(current);

  // A size change is a difference; comparing mismatched buffers throws.
  if (baseline.width !== actual.width || baseline.height !== actual.height) {
    writeFileSync(join(OUTPUT_DIR, `${name}.actual.png`), current);
    return { status: "size-changed", name };
  }

  const diff = new PNG({ width: baseline.width, height: baseline.height });
  const changed = pixelmatch(
    baseline.data, actual.data, diff.data,
    baseline.width, baseline.height,
    { threshold: 0.1, includeAA: false },
  );

  const ratio = changed / (baseline.width * baseline.height);
  if (ratio > maxDiffRatio) {
    writeFileSync(join(OUTPUT_DIR, `${name}.actual.png`), current);
    writeFileSync(join(OUTPUT_DIR, `${name}.diff.png`), PNG.sync.write(diff));
    return { status: "changed", name, changed, ratio };
  }

  return { status: "passed", name, changed, ratio };
}

Using it

js
import puppeteer from "puppeteer";
import { checkPage } from "./visual.js";

const ROUTES = ["/", "/pricing", "/about"];

const browser = await puppeteer.launch({
  args: ["--font-render-hinting=none", "--disable-lcd-text"],
});

const results = [];
for (const route of ROUTES) {
  const page = await browser.newPage();
  await page.setViewport({ width: 1280, height: 800, deviceScaleFactor: 1 });
  await prepare(page);
  await page.goto(`http://localhost:3000${route}`, { waitUntil: "networkidle0" });
  await page.evaluate(() => document.fonts.ready);
  results.push(await checkPage(page, route === "/" ? "home" : route.slice(1)));
  await page.close();
}

await browser.close();

const failed = results.filter((r) => r.status === "changed" || r.status === "size-changed");
if (failed.length) {
  console.error("Visual differences:", failed);
  process.exit(1);
}

The --font-render-hinting=none and --disable-lcd-text launch flags are worth keeping: they make text rendering more consistent between machines, which reduces the baseline noise you would otherwise absorb with a looser threshold.

The determinism you have to add

Puppeteer gives you no help here at all. Everything below is code you write.

js
async function prepare(page) {
  // Freeze time before any application script runs.
  await page.evaluateOnNewDocument(() => {
    const FIXED = new Date("2026-01-15T12:00:00Z").valueOf();
    const OriginalDate = Date;
    // @ts-expect-error - deliberate global override for test determinism
    window.Date = class extends OriginalDate {
      constructor(...args) {
        super(...(args.length ? args : [FIXED]));
      }
      static now() { return FIXED; }
    };

    let seed = 42;
    Math.random = () => {
      seed = (seed * 1103515245 + 12345) % 2147483648;
      return seed / 2147483648;
    };
  });

  // Kill animation and the text caret.
  await page.evaluateOnNewDocument(() => {
    const style = document.createElement("style");
    style.textContent = `*, *::before, *::after {
      animation: none !important;
      transition: none !important;
      caret-color: transparent !important;
    }`;
    document.addEventListener("DOMContentLoaded", () => document.head.appendChild(style));
  });

  // Stub anything that would otherwise vary run to run.
  await page.setRequestInterception(true);
  page.on("request", (request) => {
    if (request.url().includes("/api/metrics")) {
      return request.respond({
        contentType: "application/json",
        body: JSON.stringify({ runs: 128, passRate: 0.97 }),
      });
    }
    request.continue();
  });
}

That is roughly 40 lines to reach the determinism that a purpose-built tool provides by default — and it is the minimum, not the finished version. The full list of causes is in how to fix flaky visual tests.

When to stop building this

Rolling your own is genuinely the right call for a handful of pages on a stable site. It stops being the right call at recognisable points:

  • You need a review workflow. The moment a second person has to judge a difference, writing files to a directory stops working. You are now building a web application.
  • Baselines need to be branch-aware. Comparing a feature branch against the right version of main is a real problem, and it is what reg-suit exists to solve — see screenshot comparison tools.
  • Coverage has to keep up with the product. Your ROUTES array is a hand-maintained list that silently omits every page added after you wrote it.
  • You want cross-browser. Puppeteer is Chromium-only. Playwright gives you Firefox and WebKit for the same effort — see the guide.

If you are starting fresh in 2026 and visual testing is the goal, Playwright is the better foundation: comparison, retry-until-stable, and multi-engine support all ship with it. Puppeteer remains a good choice when it is already in your stack for other reasons and you only need a few pages checked.

Frequently asked questions

Does Puppeteer support visual regression testing?

Not on its own. Puppeteer captures screenshots with page.screenshot() but has no baseline, comparison, or approval concept. Visual regression testing in Puppeteer means combining it with a comparison library such as pixelmatch and building baseline storage and reporting yourself.

How do you compare screenshots in Puppeteer?

Capture with page.screenshot(), decode both the new image and the stored baseline with pngjs, then pass their pixel buffers to pixelmatch with a threshold. Handle differing image dimensions explicitly, because pixelmatch throws when buffer sizes do not match — which is exactly what happens when a layout change alters page height.

Should I use Puppeteer or Playwright for visual testing?

Playwright, if you are starting fresh. It ships visual comparison built in, retries until the rendered image is stable before diffing, and supports Chromium, Firefox and WebKit. Puppeteer is Chromium-only and requires you to build the comparison layer yourself.

Sources

  1. pixelmatch repositorychecked 2026-09-13
  2. ODiff repositorychecked 2026-09-13
  3. Playwright visual comparisons documentationchecked 2026-09-13

Referenced by