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.
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.
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
-
toMatchSnapshotwrites 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.snapfile 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 -uperiodically 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.
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.
Related
- Seeding Faker for Reproducible Test Data — the seeding these snapshots depend on
- Building a Reusable Fixture Factory — factories that produce stable records by identifier
- Caching Generated Mock Fixtures in CI — reusing the same generated data across pipeline stages
← Back to Deterministic Seed Management