Playwright toHaveScreenshot vs jest-image-snapshot

This page is part of the tool comparisons section of the visual regression guide, which covers choosing between self-hosted and cloud visual testing.

The specific question: both Playwright’s toHaveScreenshot and jest-image-snapshot are self-hosted, store baselines in the repository, and diff with pixelmatch. What actually differs, and which should a component library use?

Problem statement

The two are frequently presented as alternatives, which obscures the real distinction: toHaveScreenshot is an assertion and a capture mechanism, while jest-image-snapshot is an assertion only. It compares whatever image buffer you give it and has no opinion about where that buffer came from.

The symptom of getting this wrong is a project that installs jest-image-snapshot and then discovers it still needs Playwright or Puppeteer to produce the screenshot — at which point it is running two tools where one would have done.

Root cause

They were built for different starting points. Playwright’s matcher assumes you are already in a Playwright test with a page, and gives you capture, comparison, retries, traces and an HTML report as one integrated thing.

jest-image-snapshot assumes you already have an image and are already in Jest. That is genuinely useful when the image comes from somewhere other than a browser — a canvas rendering, a PDF page, a server-side rasteriser — and redundant when it comes from a browser you are already driving.

The two APIs, side by side Seven rows. Playwright's toHaveScreenshot captures from its own real browser while jest-image-snapshot compares whatever buffer you hand it. Playwright uses a built-in pixelmatch comparator; jest-image-snapshot offers pixelmatch or SSIM. Playwright exposes per-pixel, absolute and ratio thresholds; jest-image-snapshot exposes a failure threshold with a type. Baseline paths come from a config template or a custom directory option respectively. Playwright brings retries and traces; jest-image-snapshot inherits whatever the runner provides. The update flags differ, and each runs under its own test framework. toHaveScreenshot jest-image-snapshot Captures Playwright, real browser whatever you hand it Comparator built-in pixelmatch pixelmatch or SSIM Threshold options threshold, pixels, ratio failureThreshold + type Baseline path config template customSnapshotsDir Retries and traces built in your runner's Update flag --update-snapshots --ci=false / -u Runs under Playwright test Jest or Vitest

Minimal reproduction

// Playwright: capture and assertion in one call
await expect(page.locator('#storybook-root')).toHaveScreenshot('card.png', {
  maxDiffPixelRatio: 0.002,
  animations: 'disabled',
});
// jest-image-snapshot: you supply the image
import { toMatchImageSnapshot } from 'jest-image-snapshot';
expect.extend({ toMatchImageSnapshot });

const image = await page.screenshot();        // still needs a browser driver
expect(image).toMatchImageSnapshot({
  failureThreshold: 0.002,
  failureThresholdType: 'percent',
});

Step-by-step comparison

1. Compare what each brings to a component library

The runner you already have decides A decision node asking what already runs your tests. A Playwright suite uses toHaveScreenshot with no extra dependency. A Jest or Vitest suite uses jest-image-snapshot plus something to produce the image. A codebase running both should use each within its own tier rather than mixing them in one file. Under the Storybook test-runner either works, because the postVisit hook hands you a Playwright page. What already runs your tests? toHaveScreenshot no extra dependency Playwright jest-image-snapshot plus a capture source Jest or Vitest only Use each in its tier do not mix in one file Both, different tiers Either, via the page postVisit gives you both Storybook test-runner

For a component library driven by Playwright or the Storybook test-runner, toHaveScreenshot is the smaller setup: no extra dependency, and retries, traces and the HTML report come with it.

2. Compare the threshold models

// Playwright: three independent knobs
await expect(el).toHaveScreenshot('x.png', {
  threshold: 0.2,            // per-pixel colour distance, 0–1
  maxDiffPixels: 400,        // absolute count
  maxDiffPixelRatio: 0.002,  // fraction of the image
});
// jest-image-snapshot: one threshold, with a type
expect(image).toMatchImageSnapshot({
  failureThreshold: 0.002,
  failureThresholdType: 'percent',   // or 'pixel'
  comparisonMethod: 'ssim',          // structural comparison, if wanted
});

The practical difference is that Playwright separates per-pixel tolerance from whole-image budget, which is what lets a component absorb anti-aliasing noise while still failing on a real shift. jest-image-snapshot compensates by offering SSIM, which absorbs that noise structurally instead.

3. Compare what happens on failure

# Playwright writes three images and an HTML report entry
test-results/card-chromium/card-expected.png
test-results/card-chromium/card-actual.png
test-results/card-chromium/card-diff.png
# jest-image-snapshot writes a composite
__image_snapshots__/__diff_output__/card-diff.png

Playwright’s separate expected, actual and diff images are easier to review; the composite is more compact.

4. Compare how each handles a browser matrix

Playwright projects give per-browser baselines and per-browser results for free. Under jest-image-snapshot the browser is something your own code chose, so distinguishing baselines by engine means building the path yourself with customSnapshotIdentifier.

5. Decide by what already runs

If Playwright is already in the stack, toHaveScreenshot costs nothing to adopt and removes a dependency. If the images do not come from a browser at all, jest-image-snapshot is the only one of the two that applies.

Verification

Both report the same regression, differently The same regression reported by each tool. Playwright's toHaveScreenshot reports a failed screenshot comparison with 1842 differing pixels at a ratio of 0.0184 and writes expected, actual and diff images to its results directory. jest-image-snapshot reports that the image did not match the stored snapshot, quotes 1.84 percent differing, and writes to its own diff output directory. the same regression, both tools toHaveScreenshot: Error: Screenshot comparison failed 1842 pixels (ratio 0.0184) differ Expected/Actual/Diff written to test-results/ jest-image-snapshot: Expected image to match stored snapshot 1.84% differing — see __diff_output__/

Both detect the same regression at the same magnitude, because both are pixelmatch underneath at their defaults. Any difference in verdict comes from the threshold model rather than from detection ability — which is worth confirming on your own components before switching, by running both against a deliberate one-pixel shift.

Edge cases and caveats

  • Mixing both in one repository. Workable, and a source of confusion: two baseline directories, two update commands, two threshold vocabularies. If both are genuinely needed, keep them in clearly separate tiers.

  • jest-image-snapshot under Vitest. It works via expect.extend, but the update flag differs from Jest’s, and CI detection is based on the CI environment variable — which means a local run with CI=true refuses to write new baselines, exactly as intended and often surprising.

  • Playwright’s implicit retry. toHaveScreenshot retries the capture until it is stable or the timeout expires, which masks mild non-determinism. Helpful in practice, and worth knowing when a story looks stable under Playwright and flaky elsewhere.

Migrating between them

Teams usually move in one direction — from jest-image-snapshot plus a browser driver to Playwright’s matcher — because it removes a dependency and a framework. The migration is mechanical, and the one thing that cannot carry over is the baselines themselves.

Baselines must be regenerated, not moved. The two tools capture through different code paths, and even at the same viewport and scale factor the resulting PNGs differ in encoding and often in a handful of pixels. Copying the old images into the new path produces a suite that fails everywhere on its first run for reasons that have nothing to do with the components.

Thresholds must be re-derived. A failureThreshold of 0.002 as a percentage is not the same policy as maxDiffPixelRatio: 0.002 combined with a per-pixel threshold, because the second lets anti-aliasing noise be absorbed before the ratio is computed. Re-measure the noise floor after the switch rather than porting the number.

Do it in one pull request that changes nothing else. Every baseline in the suite is about to be rewritten, so the only way the change is reviewable is if that rewrite is the sole explanation for every diff in it — the same one-cause property that makes any large baseline change reviewable.

Keep the story ids identical. If the baseline filenames stay derived from the same story ids, the new directory maps one-to-one onto the old, which makes it possible to spot-check that nothing was dropped in the move.

The migration is worth doing when Playwright is already in the stack for other reasons. It is not worth doing to chase a marginal difference in comparison quality, because at their defaults there is not one.

FAQ

Is one more accurate than the other?

No — at their defaults both use pixelmatch, so the same change produces the same pixel count. What differs is how that count is turned into a verdict: Playwright’s separate per-pixel and whole-image thresholds, against a single threshold with a type. Neither model is more correct, and both can express the same policy with different numbers.

Which should a new component library choose?

Whichever matches the runner already in place, and if there is none, Playwright — because the visual tier needs a real browser anyway, and choosing the tool that supplies one avoids running a second framework purely to produce images.

Can jest-image-snapshot compare something that is not a screenshot?

Yes, and that is where it is genuinely irreplaceable. It compares any PNG buffer, so a canvas export, a server-rendered chart, a rasterised PDF page or an image-processing pipeline’s output can all be baselined with it. Playwright’s matcher is tied to a page, so none of those are within its reach.

Do the two produce different baseline file sizes?

Slightly, because they encode PNGs through different libraries, and the difference is not worth optimising for. What does matter for size is the capture: pinning the device scale factor to 1 and scoping the shot to the component rather than the page changes file sizes by an order of magnitude, and that lever is available in both tools equally.