UI Verify
Blog
8 min read

Snapshot testing vs visual regression testing

Snapshot testing in Jest and Vitest saves a serialized tree, not pixels. Why it misses CSS regressions, churns on refactors, and when a screenshot fits.

Igor LuchenkovIgor LuchenkovBuilding UI Verify
snapshot testingjestvitestplaywrightvisual regression testing
A one-line diff in tokens.css renaming --brand to --color-brand, the Jest run after it reporting 1 snapshot passed, and the order summary card before and after the commit: the teal Place order button is gone in the after frame.
On this page

The commit on the cover renames one design token in tokens.css, from --brand to --color-brand. The stylesheet that paints the checkout button still reads var(--brand), which no longer resolves, so the button's background drops to transparent and its white label sits on a white card. The Place order button is gone. Jest ran the component's snapshot test on that commit and printed Snapshots: 1 passed. The .snap file did not change by a single byte, because nothing it records had changed.

That run comes from a scratch project built for this post: a small React order summary with a Jest snapshot test, a Vitest snapshot test, and two Playwright tests, all aimed at the same component. Every number and terminal line below is from running it.

What is snapshot testing?

Snapshot testing saves the output of a test the first time it runs and fails any later run whose output differs from the saved copy. The saved copy is the snapshot, and what it contains decides what the test can catch. In Jest and Vitest, toMatchSnapshot() serializes a value to text and writes it to a .snap file beside the test; for a component, that value is usually the rendered DOM tree. In Playwright, toHaveScreenshot() saves a PNG of what the browser painted, and Vitest browser mode has the image kind too, toMatchScreenshot(). People call both of these snapshot testing. The image kind is what most of them mean by visual regression testing, and the two fail on nearly opposite changes.

What does a Jest or Vitest snapshot actually store?

A serialized snapshot stores markup: element names, attributes, class strings, and text, pretty-printed one attribute per line. It holds no color, no size, no position, and no record of whether anything is visible. The order summary's .snap says the button has class="btn btn-primary". What .btn-primary resolves to lives in a stylesheet the snapshot never reads.

The test compares the left panel. The color comes from the right one, through a variable the commit just renamed, and the snapshot has no line that could move.

Two things keep appearance out of the picture. The test renders into jsdom, a JavaScript model of the DOM with no layout engine and no paint step, so the serializer has no computed style to record even when a stylesheet is present. And in most Jest setups CSS imports are replaced by a stub through a module mapper; in this project the styles load from the app entry, which the unit test never imports. The serialized snapshot is blind by construction to the layer where a renamed token, a clipped overflow, a stacking-order fight, or a missing web font shows up.

Snapshot testing vs visual regression testing, change by change

Line the two up against the kinds of change a frontend pull request actually makes, and they come out close to complements. The first two rows are the runs in this post; the rest follow from what each format stores.

Change in the PRSerialized snapshot (Jest, Vitest)Visual snapshot (screenshot)
CSS only, like the token renamePasses: the markup is identicalFails: 49,699 pixels in this run
Markup refactor that renders the sameFails: 36 lines of .snap churnPasses: byte-identical PNG
Visible copy changeFailsFails
An attribute nobody sees, like aria-label or hrefFailsPasses
Layout breaks at one viewport widthPassesFails, if that width is captured
Web font fails to loadPassesFails
The serialized snapshot tracks structure; the screenshot tracks appearance.

Neither column is a superset of the other. The serialized snapshot is sensitive to code structure and blind to appearance, while the screenshot is the reverse, so the right one depends on which of those rows you are trying to protect.

Why do teams delete their Jest snapshot tests?

Teams delete serialized snapshot tests because most of their failures are changes nobody needs to review, and the fix for each one is to regenerate the file without reading it. The second run from the scratch project shows the pattern. It is a small accessibility and testability pass: the wrapper div becomes a labelled section, the rows gain data-testid attributes for an end-to-end suite, and each price is wrapped in a <data> element carrying a machine-readable value. None of it is visible.

A refactor with no visual effect. Vitest fails with a diff longer than a screen, Playwright passes, and the screenshots before and after are byte-identical files.

Vitest failed with a diff that runs past a full screen, and accepting it with vitest -u put 36 changed lines of .snap into a pull request whose component change was 22 lines. Playwright passed on the same commit. Repeat that across a few hundred components and a team learns one reflex: snapshot fails, run -u, commit. A check that gets regenerated every time without being read has stopped checking anything, and deleting it is the logical next step.

There is a subtler cost hiding in that diff. aria-labelledby changes what a screen reader announces for the card, so one of those green lines is a real behavior change. It sits in the same red and green as the data-testid noise. When a serialized snapshot does catch something meaningful, it arrives mixed into churn it has no way to filter.

A frontend engineer at a large software company described the end of that arc. His team removed their snapshot tests on purpose: they pinned React render output rather than anything a user would see, and keeping them green had turned into a formality. A separate QA group there had set up whole-page screenshot testing, a different tool aimed at a different layer. A popular r/webdev thread titled "Not once in 12 years have I found UI snapshot testing useful" split along the same seam, with its most upvoted reply defending automated screenshot diffs and writing off DOM snapshots.

What does a visual snapshot catch that a serialized one misses?

A visual snapshot catches anything that changes the rendered pixels, whichever file caused it. Back on the token-rename branch, the Playwright test opens the same component in real Chromium and compares an element screenshot against the baseline it saved on main:

e2e/summary.spec.ts
test("order summary", async ({ page }) => {
  await page.goto("http://127.0.0.1:4791/");
  await expect(page.locator(".summary")).toHaveScreenshot("order-summary.png");
});
Same commit that Jest passed. The screenshot fails on 49,699 pixels, 13% of the element, and the diff paints the button that disappeared. The aria snapshot in the same run passes.

The same run includes a second Playwright test using toMatchAriaSnapshot(), which serializes the accessibility tree to YAML and compares it against a template. It passed. The button is still a button named Place order; it is just invisible. An aria snapshot is a serialized snapshot too, so it inherits the blind spot, even though it runs in a real browser.

The pixel version has its own costs. It needs a real browser and a render that holds still, and its baselines are images. Playwright named this one order-summary-darwin.png because it was captured on macOS, and a Linux CI runner looks for a different file and rasterizes text differently; Vitest screenshot testing: why CI baselines fail walks through that problem. Keeping the clock, animations, fonts, and live data still is its own checklist, in deterministic captures.

When should you keep a serialized snapshot?

Keep a serialized snapshot when its output is text a reviewer will actually read and the exact text is the contract. A few places it still pays:

  • Small inline snapshots. toMatchInlineSnapshot() writes the expected value into the test file, so a short object or an error message gets reviewed in the same diff as the code that produced it.
  • Output with no rendering layer. A CLI's help text, a generated SQL string, a serialized config, a reducer's resulting state, an API payload. There is nothing visual for the snapshot to miss.
  • A narrow slice of markup on purpose. Assert the handful of aria-* attributes on a menu, or the href set in a nav, instead of serializing the whole component.

The case to retire is the whole-component toMatchSnapshot() over rendered JSX. It fails on the refactors you do not care about and passes on the regressions users notice. If the question behind it was whether a component looks different after a change, only pixels can answer that.

What does snapshot testing mean in Playwright and Vitest?

In Playwright, snapshot testing can mean any of three assertions: toHaveScreenshot() compares pixels, toMatchAriaSnapshot() compares the accessibility tree, and expect(value).toMatchSnapshot() compares an arbitrary string or buffer against a stored file. Only the first is a visual regression test. In Vitest, toMatchSnapshot() and toMatchInlineSnapshot() are the Jest-compatible serialized kind, written under a // Vitest Snapshot v1 header instead of Jest's, while toMatchScreenshot() in browser mode is the image kind. So when a teammate says the snapshot tests are flaky or useless, ask which file extension they mean. A .snap and a .png fail for different reasons and get fixed in different ways.

Where a visual testing service fits

The built-in screenshot assertions are a good start for a few components on one machine. As the suite grows, the cost shifts from writing assertions to living with them: a baseline per operating system, PNGs committed to git, and a person opening every diff. A visual testing service takes over that part. UI Verify replays the Storybook stories, Playwright tests, or Vitest browser tests you already have, renders every screenshot in one pinned cloud environment, keeps baselines per branch outside your repository, and runs an AI judge over each changed screenshot to label it an intended change or a likely regression. The Playwright quickstart and the Vitest quickstart add it to an existing suite.

So the answer to whether a team should keep its snapshot tests is usually a split. Keep the small inline ones that guard text. Retire the whole-component .snap files everyone regenerates on reflex. Move the coverage they were meant to give, did this component change how it looks, onto a screenshot, the only kind of snapshot that ever looked at the screen.

Snapshot what your users actually see

UI Verify screenshots the Storybook, Playwright, or Vitest tests you already run, in one fixed environment, and an AI judge labels each changed screenshot intended or a likely regression.

Start for free

No credit card required.

ShareXLinkedIn
Related skill

Deterministic Playwright captures

Stop real-page diffs that flake without a real change.