Skip to main content
Back to Blog
Guide
2026-08-08

Jest Snapshot Obsolete Cleanup CI: Detect Drift Without Hiding Regressions

Implement Jest snapshot obsolete cleanup CI checks that detect stale artifacts, produce reviewable diffs, and prevent accidental snapshot rewrites from masking defects.

Jest Snapshot Obsolete Cleanup CI: Detect Drift Without Hiding Regressions

A safe Jest snapshot obsolete cleanup CI workflow has two distinct modes. Pull request verification runs Jest in CI mode without updating snapshots, so new, mismatched, and obsolete snapshot state fails visibly. A separate drift check runs the complete owning test project with --updateSnapshot in a disposable CI workspace, then uses git diff --exit-code to prove that the committed snapshot files were already current. CI reports the patch as an artifact or log, but it never pushes the rewrite automatically.

That separation protects review intent. Snapshot updates are source changes, not housekeeping that a green job should quietly perform. An obsolete entry may mean a renamed test, a deleted case, a test that stopped executing, or a feature that was removed. The correct response depends on why it became unreachable. Cleanup must make that evidence easier to inspect, not erase it before anyone looks.

This article assumes Jest's built-in snapshot support and a GitHub Actions runner, although the control flow works in other CI systems. For broader runner selection, see the JavaScript testing frameworks guide. If browser-facing assertions are replacing brittle UI snapshots, use the Playwright locator practices guide to design resilient element checks.

Understand what “obsolete” means in a snapshot suite

Jest stores external snapshots in __snapshots__ files beside the test file by default. A snapshot entry is obsolete when the snapshot file contains a key that the executed test suite no longer claims. A whole snapshot file can also become orphaned when its associated test file disappears. These two cases look similar in a diff but require different checks.

Drift typeTypical causeWhat the reviewer must establish
Mismatched snapshotRendered or serialized output changedWhether product behavior changed intentionally
New snapshotA new snapshot assertion has no committed baselineWhether the assertion scope is appropriate
Obsolete entryTest title, parameter label, or assertion count changedWhether coverage moved or disappeared
Orphaned .snap fileTest file deleted or renamedWhether deletion is complete and intentional
Unstable snapshotTime, random IDs, locale, or ordering variesWhich nondeterministic field needs normalization

Jest documents --ci and --updateSnapshot in its CLI reference at https://jestjs.io/docs/cli and snapshot principles at https://jestjs.io/docs/snapshot-testing. In CI mode, Jest does not silently write a new snapshot. Updating snapshots is an explicit operation through --updateSnapshot, whose short alias is -u.

The important phrase is “executed test suite.” A cleanup run that selects only changed tests, a single test name, or one shard may lack enough information to declare the rest obsolete safely. Run the complete Jest project that owns the snapshot files before treating an update diff as authoritative.

Create snapshots with a narrow, stable contract

Use snapshots when the serialized representation is meaningful and reviewable. The following example snapshots an audit line after removing fields whose values vary by run:

// src/audit-event.ts
export interface AuditEvent {
  id: string;
  occurredAt: string;
  actor: { id: string; email: string };
  action: 'order.created' | 'order.cancelled';
  metadata: Record<string, string>;
}

export function auditDisplay(event: AuditEvent) {
  return {
    actor: event.actor.email,
    action: event.action,
    metadata: Object.fromEntries(
      Object.entries(event.metadata).sort(([left], [right]) =>
        left.localeCompare(right),
      ),
    ),
  };
}
// src/audit-event.test.ts
import { auditDisplay, type AuditEvent } from './audit-event';

test('formats an order-created audit event', () => {
  const event: AuditEvent = {
    id: 'evt_123',
    occurredAt: '2026-08-08T09:30:00.000Z',
    actor: { id: 'usr_7', email: 'qa@example.test' },
    action: 'order.created',
    metadata: {
      region: 'ap-south',
      orderId: 'ord_42',
    },
  };

  expect(auditDisplay(event)).toMatchSnapshot();
});

The committed snapshot is small enough to review:

// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing

exports[`formats an order-created audit event 1`] = `
{
  "action": "order.created",
  "actor": "qa@example.test",
  "metadata": {
    "orderId": "ord_42",
    "region": "ap-south",
  },
}
`;

Snapshot serializer formatting depends on the Jest version, so the exact file representation shown is illustrative. Commit the output generated by the repository's pinned version. Do not hand-author snapshot formatting as a routine practice.

What people get wrong is snapshotting everything, then trusting -u as an approval mechanism. Updating a thousand-line object proves only that Jest can serialize the new value. It does not prove that every changed field is correct. Keep the object narrow, use property matchers or explicit assertions for volatile data, and treat the update patch as a product-behavior review.

Make local update commands intentionally different from CI checks

Package scripts should communicate whether they verify or rewrite:

{
  "scripts": {
    "test": "jest",
    "test:ci": "jest --ci",
    "test:snapshots:update": "jest --updateSnapshot --runInBand",
    "test:snapshots:check": "jest --ci --runInBand"
  }
}

--runInBand is optional. It makes snapshot diagnosis and console output easier to follow, but it should not be used to conceal shared-state defects. Use the normal parallel configuration after cleanup to confirm the suite remains worker-safe.

The local reviewer workflow is:

npm ci
npm run test:snapshots:check
npm run test:snapshots:update
git diff -- '**/__snapshots__/*.snap' '*.test.ts' '*.test.tsx'
npm run test:snapshots:check

The quoted pathspecs prevent the shell from expanding them prematurely and let Git match repository paths. Adjust extensions to the repository. The update command must run all tests in the owning Jest project. If the monorepo has multiple projects, create one explicit script per project or invoke the appropriate documented Jest configuration.

CommandWrites workspace snapshotsAppropriate purpose
jest --ciNo new baseline updatesPull request gate
jest --updateSnapshotYesDeliberate local review or disposable drift probe
jest -u -t "name"Can update selected scopeTargeted authoring, not authoritative global cleanup
git diff --exit-codeNoDetect uncommitted drift after update
git status --shortNoInventory created, removed, and modified files

Do not hide --updateSnapshot inside the ordinary test script. A developer running a verification command should not rewrite evidence as a side effect.

Build the pull request verification job

The primary job installs exactly from the lockfile and runs Jest in CI mode:

# .github/workflows/test.yml
name: test

on:
  pull_request:
  push:
    branches:
      - main

permissions:
  contents: read

jobs:
  jest:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version-file: .nvmrc
          cache: npm
      - run: npm ci
      - run: npm run test:ci

This job should fail on behavior drift. It should not run git add, create a commit, or push to the pull request. Pin third-party actions according to organizational policy. The major tags shown are readable examples, not a substitute for a supply-chain review.

A pull request that intentionally changes a snapshot should include the product change, test change if needed, and reviewed snapshot patch in the same commit series. Reviewers can then connect each output difference to a requirement.

Add a disposable snapshot drift probe

The second job answers a different question: if the full suite updated snapshots now, would the repository change? It updates only the runner's ephemeral checkout, captures status, and fails when any tracked or untracked snapshot artifact differs.

# .github/workflows/snapshot-drift.yml
name: snapshot-drift

on:
  pull_request:

permissions:
  contents: read

jobs:
  snapshot-drift:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 1
      - uses: actions/setup-node@v4
        with:
          node-version-file: .nvmrc
          cache: npm
      - run: npm ci
      - name: Update snapshots in disposable checkout
        run: npm run test:snapshots:update
      - name: Show snapshot workspace status
        run: git status --short
      - name: Save tracked snapshot patch
        run: git diff --binary > /tmp/snapshot-drift.patch
      - name: Fail on tracked or untracked drift
        run: |
          untracked="$(git ls-files --others --exclude-standard)"
          if [ -n "$untracked" ]; then
            echo "Untracked files remain after snapshot update:"
            echo "$untracked"
            exit 1
          fi
          git diff --exit-code
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: snapshot-drift
          path: /tmp/snapshot-drift.patch
          if-no-files-found: ignore

git diff --exit-code detects modifications and deletions to tracked files, but it does not report untracked files, so the shell step checks both. It quotes $untracked to avoid word splitting when printing the diagnostic. If the repository legitimately generates unrelated untracked outputs, clean them through the producing tool or constrain the inventory carefully without excluding genuinely new snapshots.

The patch is generated before the detection step and uploaded only when that step fails. It contains tracked changes, while the status output identifies untracked files. Do not include secrets or sensitive rendered customer data in snapshots or uploaded patches.

Keep the drift job independent from build steps that rewrite source fixtures, generated clients, or lockfiles. Otherwise the final Git check mixes snapshot evidence with unrelated generator drift, and the failure becomes harder to assign. If tests require generated assets, produce them in an earlier verification job and check those assets separately, or establish a clean baseline after the approved generation step before updating snapshots. The snapshot probe should answer one narrow question: whether executing the complete snapshot-owning suite changes its committed test artifacts.

Catch orphaned snapshot files that Jest may not visit

When a test file is deleted, the safest orphan check compares each __snapshots__/name.snap path with its likely owner. Naming conventions can vary with extensions and transforms, so a repository-specific script is clearer than a fragile shell one-liner.

The following Node script supports test filenames ending in .test.ts, .test.tsx, .spec.ts, or .spec.tsx:

// scripts/check-orphan-snapshots.mjs
import { access, readdir } from 'node:fs/promises';
import path from 'node:path';

async function exists(file) {
  try {
    await access(file);
    return true;
  } catch {
    return false;
  }
}

async function walk(directory) {
  const entries = await readdir(directory, { withFileTypes: true });
  const files = [];
  for (const entry of entries) {
    const fullPath = path.join(directory, entry.name);
    if (entry.isDirectory()) {
      files.push(...await walk(fullPath));
    } else {
      files.push(fullPath);
    }
  }
  return files;
}

const root = process.cwd();
const allFiles = await walk(root);
const snapshots = allFiles.filter((file) =>
  file.includes(path.sep + '__snapshots__' + path.sep) &&
  file.endsWith('.snap'),
);
const orphans = [];

for (const snapshot of snapshots) {
  const testDirectory = path.dirname(path.dirname(snapshot));
  const snapshotName = path.basename(snapshot, '.snap');
  const owner = path.join(testDirectory, snapshotName);
  if (!await exists(owner)) {
    orphans.push(path.relative(root, snapshot));
  }
}

if (orphans.length > 0) {
  console.error('Orphaned snapshot files:');
  console.error(orphans.join('\n'));
  process.exitCode = 1;
}

Jest snapshot filenames commonly include the full test filename before .snap, such as audit-event.test.ts.snap. Removing only the final .snap therefore yields the owner path. The script avoids following symbolic-link directories because Dirent.isDirectory() is false for a symbolic link, which prevents accidental traversal outside the tree. In a large repository, scope root to source packages to avoid scanning dependency directories.

Add it before the drift update:

{
  "scripts": {
    "test:snapshots:orphans": "node scripts/check-orphan-snapshots.mjs",
    "test:snapshots:update": "jest --updateSnapshot --runInBand"
  }
}

This checker is convention-based. If the project uses a custom snapshot resolver, derive ownership using that same resolver rather than assuming Jest's default location.

Diagnose an obsolete snapshot before deleting it

Imagine CI reports one obsolete snapshot after a refactor from:

test.each(['draft', 'submitted'])('renders order %s', (status) => {
  expect(renderOrder(status)).toMatchSnapshot();
});

to:

test.each(['draft'])('renders order %s', (status) => {
  expect(renderOrder(status)).toMatchSnapshot();
});

Running -u will remove the submitted entry, but the diff alone cannot explain why the case disappeared. Diagnose in this order:

  1. Inspect the source diff that changed the parameter table.
  2. Find the requirement that says whether submitted remains supported.
  3. Search for an equivalent explicit assertion or test at another level.
  4. Run the pre-update test suite and capture Jest's obsolete summary.
  5. Only then update and review the exact removed key.

If submitted orders are still valid, the obsolete snapshot is a coverage regression. Restore the case or replace it with more focused assertions. If support was intentionally removed, deletion is correct, and the product change should make that decision obvious.

ObservationLikely diagnosisSafe next action
One key vanished after test renameIdentity changed, behavior may not haveReview old and new values, then update
Many snapshots changed orderingSerializer, runtime, or locale driftStabilize environment before approving
Snapshot file deleted with its testFeature or test removalConfirm replacement coverage
Obsolete notice appears only in full runFocused runs missed owning suiteKeep cleanup full-scope
Update changes product test plus unrelated snapshotsEnvironment or version mismatchReproduce from clean lockfile install

The realistic failure mode is an automatic bot running jest -u on every pull request and committing the result. A developer accidentally removes the submitted case, the bot deletes its snapshot, and the final branch is green. The cleanup automation has converted lost coverage into an apparently tidy patch. The remedy is not a smarter commit message. Stop automatic mutation, fail the drift job, and require a person to connect deletions to requirements.

Prevent nondeterminism from looking like obsolescence

Snapshot churn is often caused by unstable values rather than obsolete keys. Normalize only fields that are intentionally outside the contract. Do not erase meaningful timestamps, identifiers, or ordering simply to make the file quiet.

Property matchers can preserve structure while accepting dynamic fields:

test('creates an audit envelope', () => {
  const result = {
    id: crypto.randomUUID(),
    createdAt: new Date().toISOString(),
    action: 'order.created',
  };

  expect(result).toMatchSnapshot({
    id: expect.any(String),
    createdAt: expect.any(String),
  });
});

This sample uses globals available in the configured runtime. If crypto.randomUUID is not available in the project's supported Node environment, import randomUUID from node:crypto. The matchers assert types, but they do not validate UUID shape or timestamp parseability. Add explicit assertions when those details are part of the API contract.

Stable ordering is another common issue. Sort only collections whose contract is order-insensitive. If UI order matters, sorting in the test would mask a regression. Configure timezone and locale explicitly when output depends on them, and pin the Node and Jest versions through normal repository tooling.

Handle monorepos, shards, and changed-test selection

Snapshot cleanup and test distribution have conflicting goals. Sharding makes verification faster, while cleanup needs a complete view of every snapshot owned by the project. Keep them separate.

CI modeTest selectionMay update snapshots?Purpose
PR shardsAll tests split across jobsNoFast behavior verification
Changed testsDependency-related subsetNoDeveloper feedback
Cleanup probeFull owning Jest project, one coherent runYes, disposable onlyDetect drift
Local authoringFocused test or name patternYes, reviewedIterate on intended change

Do not have multiple shards write into a shared checkout or artifact directory. Concurrent snapshot rewrites can race, and no shard knows whether an entry belongs to a test handled elsewhere. The cleanup probe can run less frequently if the full suite is expensive, but pull request verification should still refuse unreviewed new or mismatched snapshots.

For Jest projects, execute the configuration that includes every project owning the target snapshots, or perform one cleanup command per project in stable sequence. Ensure display names and roots prevent two projects from claiming the same snapshot path.

Review snapshot patches as structured evidence

A good snapshot review asks what changed at three levels:

  • Test identity: Did a title, parameter, or assertion count change?
  • Contract shape: Were meaningful fields added, removed, or reordered?
  • Concrete values: Did user-visible text, state, permissions, or accessibility data change?

Large patches deserve decomposition. Replace a broad snapshot with smaller snapshots or explicit matchers before approving the behavior change. Do not accept a serializer upgrade and a product change in the same snapshot patch if they can be separated. A mechanical formatting rewrite makes semantic differences harder to see.

Keep snapshot files in normal code ownership. Require QA or component owners for sensitive areas, such as authorization decisions, invoices, API error bodies, and accessibility output. A snapshot is executable test data, and changes should receive the same review rigor as an assertion written inline.

Establish a cleanup policy the team can follow

The policy can fit in a short repository document:

  1. Verification never updates snapshots.
  2. Updates run from a clean dependency install with the pinned runtime.
  3. Cleanup covers the complete owning Jest project.
  4. Every removed key is explained by a requirement, rename, or replacement test.
  5. CI may create a disposable patch but never push it automatically.
  6. Orphan checks reflect the repository's snapshot resolver.
  7. Snapshot updates and serializer upgrades are separated from unrelated behavior changes.

Measure the workflow with repository-specific data: number of drift failures, time to diagnosis, repeated sources of churn, and obsolete entries that exposed missing coverage. Do not optimize for zero snapshot changes. The goal is meaningful, explainable change.

Frequently Asked Questions

Should CI run Jest with updateSnapshot enabled?

Not in the primary verification job. Use jest --ci there so snapshot drift fails without being rewritten. A separate disposable job may run jest --updateSnapshot to reveal what cleanup would change, followed by tracked and untracked file checks. That job should fail and expose the patch for review, not commit it. Keeping verification and mutation separate prevents an accidental product change or deleted test case from receiving a fresh baseline automatically.

Does git diff --exit-code catch every new snapshot?

No. It catches modifications and deletions of tracked files, but ordinary Git diff output does not include untracked files. Pair it with git ls-files --others --exclude-standard or an equivalent repository-aware check. Also print git status --short for diagnosis. If the test command generates unrelated untracked outputs, either clean them through their owning tool or constrain the check to expected snapshot paths without ignoring genuinely new snapshot files.

Can I clean obsolete snapshots with a focused Jest test name?

A focused update is useful while authoring one intentional change, but it is weak evidence for repository-wide cleanup. Test-name filters, changed-test selection, and shards do not necessarily execute every snapshot assertion in the owning project. Run the complete project before declaring entries or files obsolete. Review the focused patch locally, then run a full non-updating CI check and the full disposable drift probe to confirm no unrelated state remains.

When should I replace a snapshot with explicit assertions?

Replace it when reviewers cannot identify the contract quickly, when unrelated representation fields dominate diffs, or when a small number of business rules matter more than the complete serialized output. Explicit assertions are especially useful for authorization, totals, error codes, and state transitions. Keep snapshots for compact representations where the whole shape is meaningful. A hybrid often works best: explicit assertions for critical semantics and a small snapshot for stable presentation details.