Keeping Snapshot Tests Stable with Fixed Seeds

Every second CI run updates six snapshots and nobody reads the diff any more. The cause is almost never the component — it is generated data, a live clock, and an unstable sort. This page separates the values that should be frozen from the ones that should be normalised, so a snapshot diff means something again.

Context: churn destroys the assertion

A snapshot test asserts “the output is what it was”. Its entire value rests on a diff being rare enough that a human reads it. Once snapshots change on runs where nothing changed, the team learns to run the update command reflexively, and the test stops catching anything — including the regression it was written for.

Three things produce churn, and they need different treatments.

Random fixture values should be frozen. There is no reason for a customer name to differ between runs, and freezing it costs nothing.

Time-derived values are split. A relative label like “3 days ago” is behaviour worth asserting, so freeze the clock and let it render. A render timestamp in a debug attribute is incidental, so normalise it — freezing the clock to protect an incidental value suppresses real time-dependent bugs elsewhere.

Genuinely volatile values — a UUID minted at render time, a measured duration, a React key derived from a counter — cannot be frozen without changing the code under test. Those must be normalised in the serializer.

Freeze, normalise, or leave alone A decision tree. Starting from a value that varies between runs, the first question is whether the variation is behaviour the test should assert. If yes, freeze its source — seed the generator, fix the clock, pin the locale. If no, the second question is whether the value can be frozen without changing production code. If it can, freeze it; if it cannot, normalise it to a placeholder in the snapshot serializer. A value that varies per run Is the variation behaviour to assert? yes FREEZE the source seed the generator · fix the clock · pin locale and timezone no Can it be frozen without touching prod? FREEZE it NORMALISE it placeholder in the serializer Normalising something that should have been frozen hides a bug; freezing something that should have been normalised requires changing production code to suit a test.

Solution

1. Freeze the generator, the clock, the timezone and the locale

// vitest.setup.ts
import { afterEach, beforeEach, vi } from 'vitest';
import { faker } from './src/fixtures/faker';

// The host's timezone and locale must never leak into rendered output.
process.env.TZ = 'UTC';

const FROZEN = new Date('2026-07-31T09:00:00.000Z');

beforeEach(() => {
  faker.seed(20260731);
  vi.useFakeTimers({ shouldAdvanceTime: true });
  vi.setSystemTime(FROZEN);
});

afterEach(() => {
  vi.useRealTimers();
});

Setting process.env.TZ before any date formatting happens is the fix for the most common “passes locally, fails on CI” snapshot failure. A developer machine on Europe/London renders 31/07/2026, 10:00 where a UTC runner renders 09:00, and the component is identical in both.

2. Normalise what genuinely cannot be frozen

A custom serializer replaces volatile values with stable placeholders without touching production code:

// vitest.setup.ts (continued)
import { expect } from 'vitest';

const UUID = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi;
const DURATION = /\b\d+(\.\d+)?ms\b/g;
const REACT_KEY = /:r[0-9a-z]+:/g;      // useId output — stable per tree, not per run

expect.addSnapshotSerializer({
  test: (v) => typeof v === 'string' && (UUID.test(v) || DURATION.test(v) || REACT_KEY.test(v)),
  serialize: (v: string) =>
    `"${v.replace(UUID, '<uuid>').replace(DURATION, '<duration>').replace(REACT_KEY, '<id>')}"`,
});

Placeholders should be specific. Replacing every number with <num> would also hide the total that the test exists to check; replacing only values matching a UUID or duration shape keeps the rest of the snapshot meaningful.

3. Keep snapshots small enough to be read

// src/features/orders/OrderRow.test.tsx
import { expect, it } from 'vitest';
import { render } from '@testing-library/react';
import { buildOrder } from '../../fixtures/orders';
import { OrderRow } from './OrderRow';

it('renders a paid order row', () => {
  const order = buildOrder('ord_0001');          // seeded from its id — stable
  const { container } = render(<OrderRow order={order} />);
  expect(container.firstChild).toMatchSnapshot();
});

container.firstChild snapshots one row, not the page around it. A twenty-line snapshot gets read; a nine-hundred-line one gets updated. If a page-level assertion is genuinely needed, prefer explicit expectations on the handful of things that matter rather than a snapshot of everything.

Snapshot size against review quality Three snapshot sizes with the reviewer behaviour each produces. A twenty-line component snapshot is read line by line and a regression is caught. A two-hundred-line section snapshot is skimmed and subtle changes are missed. A nine-hundred-line page snapshot is accepted without reading, so the test catches nothing while still costing maintenance. ~20 lines one component read line by line a regression is caught ~200 lines a page section skimmed subtle changes slip through ~900 lines a whole page accepted unread cost without coverage A snapshot's value is bounded by whether a human reads its diff — which is a property of its size, not of its coverage. Reducing scope is almost always a better fix for churn than adding another normalisation rule. If a snapshot needs more than two or three placeholders, it is probably snapshotting too much.

Verification

# Two consecutive runs must produce no snapshot changes
npx vitest run --reporter=basic
npx vitest run --reporter=basic 2>&1 | grep -qi 'snapshot.*written\|obsolete' && exit 1 || echo 'snapshots stable'

# CI parity: same result under a different host timezone
TZ=Australia/Sydney npx vitest run --reporter=basic

The third command is the cheapest way to prove the timezone pin works. If the suite passes on UTC and fails on Australia/Sydney, some date is being formatted from the host clock rather than the frozen one.

Run with --ci in the pipeline so an unmatched snapshot fails rather than silently writing a new one:

      - run: npx vitest run --ci --reporter=basic

Gotchas and edge cases

  • toMatchSnapshot writes a new file on first run and passes. A snapshot added in a pull request always passes in that pull request, because it was created by the run that asserted it. Review the added .snap file as carefully as the code, since it is the only chance anyone gets to check that the recorded output is actually correct.

  • Obsolete snapshots hide deleted coverage. When a test is renamed, its old snapshot lingers and nothing fails. Run vitest run -u periodically and inspect what gets removed, or enable the reporter’s obsolete-snapshot warning in CI so the dead entries surface.

  • Freezing time can hide an expiry bug. A component that shows “expired” past a threshold will never show it if the clock is frozen before that threshold. Where time-dependent branching matters, write explicit specs that advance the clock across the boundary rather than relying on the snapshot to notice — a frozen clock proves stability, not correctness.


Triaging a churning snapshot Three questions in order for a snapshot that changes without a code change: is the value seeded, is it derived from a clock, and is it genuinely volatile. Each answer points at a different fix, and working through them in order avoids normalising something that should have been frozen. Is it seeded? names, ids, amounts from a generator seed it — do not normalise it Is it clock-derived? dates, relative labels, expiry freeze the clock, then assert it Is it genuinely volatile? a render-time uuid, a measured duration normalise it in the serializer Normalising something that should have been frozen hides the very behaviour the snapshot was written to protect.

What snapshots are good for, once they are stable

Stabilising snapshots is worth doing only if the snapshots themselves are worth keeping, and that is not automatic. A stable snapshot of the wrong thing is still a maintenance cost with no return.

Snapshots earn their place in three situations.

Structural output with many small parts. A rendered table row, a formatted address block, a generated configuration file — things where a dozen small details matter and writing an assertion per detail would be both tedious and less complete than a diff.

Output that is easier to review than to specify. Nobody wants to write assertions describing every attribute of a rendered chart legend. A snapshot captures it, and a human reviewing the diff is genuinely better at spotting a wrong value than a list of expectations would be.

Serialised artefacts that other systems consume. A generated OpenAPI document, a produced fixture file, a build manifest. These change rarely and matter a great deal when they change, which is exactly the profile a snapshot suits.

They earn nothing in two others. A snapshot of a whole page is too large to read, so its diff is accepted rather than reviewed. And a snapshot standing in for a specific behavioural assertion — that a total is calculated correctly, that a permission is enforced — hides the intent, so a future reader cannot tell what the test was protecting.

The practical rule that follows: if you can state in one sentence what a snapshot is protecting, and the sentence is not simply “that nothing changed”, it is probably a good snapshot. If you cannot, an explicit assertion will serve better and will still be legible in a year.

Stabilising the seeds is what makes either kind of test possible at all — but it is worth spending the effort on the snapshots that were worth having.

← Back to Deterministic Seed Management