Skip to content
Rémy Duthu Rémy Duthu
April 25, 2026 · 7 min read

Jest snapshot drift: when toMatchSnapshot lies about your code

Jest snapshot drift: when toMatchSnapshot lies about your code

Snapshots fail every few days with diffs you did not cause. Most teams blame their Date.now() mock. The real story involves three less-obvious sources of drift, and a one-line serializer that fixes them.

You ran the suite. A snapshot test failed with a diff like this:

- "Generated at 2026-04-21T09:14:32.118Z"
+ "Generated at 2026-04-21T09:14:32.119Z"

Nobody touched the file. The diff is one millisecond. You re-run, it passes. You commit something unrelated, it fails again with 2026-04-21T09:14:33.001Z. You add jest.setSystemTime(new Date("2026-01-01")) to the test, ship the PR, and a week later the same kind of diff shows up in a different snapshot. The clock is not the problem.

This is snapshot drift: the test serializes mutable state that nothing in your code controls. The setSystemTime fix only works for tests where Date.now() is the only moving part. Most snapshot suites have at least three other sources of drift, and they show up as flakes you cannot debug because the diff blames the snapshot, not the source.

The three sources of drift setSystemTime does not catch

1. Generated identifiers

function Receipt() {
  const id = crypto.randomUUID();
  return <div data-id={id}>...</div>;
}

test("renders receipt", () => {
  expect(renderer.create(<Receipt />).toJSON()).toMatchSnapshot();
});

Every render produces a fresh UUID. Your snapshot captures one of them. The next run produces a different one. The diff is 36 characters of hex.

You cannot fix this with setSystemTime because the UUID is not derived from the clock (well-implemented UUID v4 is fully random, and v7 mixes in randomness even when it includes a timestamp). You either mock the generator or teach the snapshot how to ignore it.

2. Map and Set iteration order

const seen = new Set<string>();
items.forEach((item) => {
  if (item.featured) seen.add(item.category);
});
expect(Array.from(seen)).toMatchSnapshot();

Set preserves insertion order. So far so good. But your test fixture is built from an upstream API call, and that API does not guarantee a stable ordering. The test passes when the API returns categories in alphabetical order; it fails when the API switches to relevance ordering after a backend deploy.

This one is sneaky because Set looks like an unordered structure to most people. It is ordered, but you do not control the order. If the input is non-deterministic, the snapshot is non-deterministic too.

3. Global counters and incrementing IDs

let nextId = 1;
export function createRequest() {
  return { id: nextId++, ... };
}

Run the suite once: the first request in your test gets ID 1. Run the suite again with one extra createRequest call elsewhere: the first request in your test gets ID 2. The snapshot moved by one digit and the diff is meaningless.

This pattern is common in code that pre-dates UUIDs. It is also one of the hardest to spot because the source of the increment is often outside the test file you are looking at.

The one-line serializer that catches all three

Jest snapshots support custom serializers. Most teams use them for prettier diffs (collapsing whitespace, prettifying JSX), but the underused superpower is replacing values with stable placeholders.

// jest.setup.ts
expect.addSnapshotSerializer({
  test: (val) => typeof val === "string" && /^[\da-f]{8}-[\da-f]{4}/.test(val),
  print: () => '"<uuid>"',
});

expect.addSnapshotSerializer({
  test: (val) =>
    typeof val === "string" &&
    /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(val),
  print: () => '"<isodate>"',
});

Now any UUID-shaped string in any snapshot in any test serializes as "<uuid>". Same for ISO datetimes. The snapshot still asserts on the structure (the field is still a string, it is still where you expected it to be), but the unstable value is replaced with a stable token. Reviews show the structural change you actually made instead of the timestamp churn.

For Map/Set ordering, normalize at assertion time:

expect(Array.from(seen).sort()).toMatchSnapshot();

Yes, you lose the original order. That is the point. The original order was an artifact of upstream behavior, not part of the contract your test is defending.

For global counter IDs, the cleanest fix is a beforeEach reset, but the more honest fix is to stop using global counters in your domain types and use UUIDs (then catch them with the serializer).

When snapshots are the wrong tool

Sometimes the real answer is “do not snapshot this”. If a value is intrinsically variable (a timestamp on a render, a session token, a CSRF nonce), capturing it in a snapshot guarantees noise. Use property matchers instead:

expect(receipt).toMatchSnapshot({
  generatedAt: expect.any(String),
  receiptId: expect.any(String),
});

The structure is asserted, the variable bits are checked for shape only. This is what the Jest property-matcher docs are for, and they are oddly underused considering how cleanly they solve drift.

How Mergify makes snapshot drift legible

Without tooling, snapshot drift produces failures that look identical to broken tests. Confidence drops, engineers stop trusting the suite, and the response is usually to delete the snapshot and re-record it. That is not a fix. That is hiding the problem until next week.

Test Insights groups snapshot failures by their diff signature. When the same test fails three times in a week with three different timestamps, the dashboard surfaces it as a single drift pattern instead of three independent failures. Quarantine kicks in once the pattern is confirmed, so the merge queue does not block on a test that needs a serializer, not a code fix.

If you want to see which of your snapshots are drifting, point Mergify at your repo. It reads JUnit XML output from jest-junit or any compatible reporter and starts scoring snapshots immediately.

More patterns where state crosses tests

Snapshot drift is one variant of a more general theme: shared state that nothing reset. We catalog seven more in the flaky-tests-in-Jest guide. Fake timers leaking into the next test, module mocks hoisting above your imports, test.concurrent racing on counters, unhandled promises landing on the wrong test’s microtask queue. Different surfaces, same root cause.

The fixes are all small. The hard part is naming what you are looking at.

Test Insights

Tired of flaky tests blocking your pipeline?

Test Insights detects flaky tests, quarantines them automatically, and tracks test health across your suite.

Try Test Insights

Recommended posts

Fake-timer leakage in Jest: the flake nobody sees coming
April 25, 2026 · 6 min read

Fake-timer leakage in Jest: the flake nobody sees coming

Why a single jest.useFakeTimers() call can pollute the next test in the file, what the failure looks like, and the three-line afterEach that makes it go away for good.

Rémy Duthu Rémy Duthu
A merge queue is critical infrastructure. Build it accordingly.
April 25, 2026 · 5 min read

A merge queue is critical infrastructure. Build it accordingly.

On April 23, GitHub's merge queue silently corrupted merges for four and a half hours. The failure mode is structural, and it shows what it takes to build a merge queue at the level of critical infrastructure.

Julien Danjou Julien Danjou
Switching from npm to pnpm found 3 phantom dependencies in our React app
April 20, 2026 · 5 min read

Switching from npm to pnpm found 3 phantom dependencies in our React app

A pnpm migration meant to speed up installs ended up exposing three phantom dependencies our React app had been shipping without declaring.

Thomas Berdy Thomas Berdy