A

Snapshot Testing

testingsnapshotsjestvitestregression-testing

Snapshot Testing

Snapshot testing captures the output of code and compares it against a stored reference. When the output changes, the test fails—prompting you to review whether the change is intentional. Snapshots work well for testing serializable output like JSON, rendered components, or error messages. Used carelessly, they create maintenance burdens and false confidence.

How Snapshots Work

  1. First run: Test captures output and saves it to a .snap file
  2. Subsequent runs: Test compares current output against stored snapshot
  3. Difference detected: Test fails, showing the diff
  4. Intentional change: Developer updates snapshot to accept new output
// src/config.test.ts
import { describe, it, expect } from 'vitest';
import { generateConfig } from './config';

it('generates production config', () => {
  const config = generateConfig({ env: 'production' });
  expect(config).toMatchSnapshot();
});

First run creates src/__snapshots__/config.test.ts.snap:

// Vitest Snapshot v1

exports[`generates production config 1`] = `
{
  "cache": true,
  "debug": false,
  "env": "production",
  "minify": true,
}
`;

If generateConfig changes its output, the test fails:

- "debug": false,
+ "debug": true,

Good Use Cases

Serialized Output

Snapshots excel at capturing structured data:

// API response structure
it('formats user response', () => {
  const response = formatUserResponse(mockUser);
  expect(response).toMatchSnapshot();
});

// Configuration objects
it('generates webpack config', () => {
  const config = createWebpackConfig({ mode: 'production' });
  expect(config).toMatchSnapshot();
});

// CLI output
it('displays help message', () => {
  const output = cli.getHelp();
  expect(output).toMatchSnapshot();
});

Error Messages

Ensure error messages remain stable:

it('provides helpful validation error', () => {
  const result = validateEmail('not-an-email');
  expect(result.error).toMatchSnapshot();
});

// Snapshot:
exports[`provides helpful validation error 1`] = `"Invalid email format. Expected format: [email protected]"`;

Transformed Output

Test code transformations, serializers, formatters:

it('transforms markdown to HTML', () => {
  const html = markdownToHtml('# Hello\n\nWorld');
  expect(html).toMatchSnapshot();
});

it('serializes data for export', () => {
  const csv = toCSV([{ name: 'John', age: 30 }]);
  expect(csv).toMatchSnapshot();
});

Component Rendering (with caveats)

Small, stable components can benefit from snapshots:

// Good: small, stable component
it('renders badge', () => {
  const { container } = render(<Badge status="success" />);
  expect(container).toMatchSnapshot();
});

// Snapshot is manageable:
exports[`renders badge 1`] = `
<div>
  <span
    class="badge badge-success"
  >
    Success
  </span>
</div>
`;

Bad Use Cases

Large DOM Trees

Large snapshots are unreadable and unmaintainable:

// ❌ Avoid: snapshot will be hundreds of lines
it('renders dashboard', () => {
  const { container } = render(<Dashboard user={mockUser} />);
  expect(container).toMatchSnapshot();
});

Problem: When this fails, the diff is overwhelming. Reviewers rubber-stamp updates without careful inspection.

Better approach: Test specific behaviors:

// ✅ Test specific behaviors
it('displays user name in header', () => {
  render(<Dashboard user={mockUser} />);
  expect(screen.getByRole('heading')).toHaveTextContent('John Doe');
});

it('shows notification count', () => {
  render(<Dashboard user={{ ...mockUser, notifications: 5 }} />);
  expect(screen.getByTestId('notification-badge')).toHaveTextContent('5');
});

Frequently Changing UI

If snapshots break on every change, they're noise:

// ❌ Component changes frequently during development
it('renders new feature', () => {
  const { container } = render(<NewFeature />);
  expect(container).toMatchSnapshot();  // Updated 47 times this sprint
});

Better: Wait until the component stabilizes, then add focused tests.

Implementation Details

Don't snapshot internal structures:

// ❌ Tests implementation, not behavior
it('creates internal state', () => {
  const service = new UserService();
  expect(service._internalState).toMatchSnapshot();
});

Random or Time-Based Data

Snapshots with variable data fail intermittently:

// ❌ Contains timestamp, will fail
it('creates log entry', () => {
  const entry = createLogEntry('message');
  expect(entry).toMatchSnapshot();
  // { message: 'message', timestamp: '2024-01-15T10:30:00Z' } ← changes!
});

// ✅ Either mock time or exclude variable fields
it('creates log entry', () => {
  vi.setSystemTime(new Date('2024-01-01'));
  const entry = createLogEntry('message');
  expect(entry).toMatchSnapshot();
});

// ✅ Or use property matchers
it('creates log entry', () => {
  const entry = createLogEntry('message');
  expect(entry).toMatchSnapshot({
    timestamp: expect.any(String),
  });
});

Inline Snapshots

Store snapshots directly in test files:

it('formats currency', () => {
  expect(formatCurrency(1234.5)).toMatchInlineSnapshot(`"$1,234.50"`);
});

it('generates slug', () => {
  expect(slugify('Hello World!')).toMatchInlineSnapshot(`"hello-world"`);
});

Advantages:

  • No separate .snap file to manage
  • See expected value immediately in test
  • Better for small, simple values

Disadvantages:

  • Clutters test file if snapshot is large
  • Must use tool to update (can't manually edit)

Run tests with update flag to populate inline snapshots:

pnpm test -- -u

Property Matchers

Use property matchers for dynamic values:

it('creates user with generated ID', () => {
  const user = createUser({ name: 'John' });

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

// Snapshot stores structure but allows dynamic values:
exports[`creates user with generated ID 1`] = `
{
  "createdAt": Any<Date>,
  "id": Any<String>,
  "name": "John",
}
`;

Available matchers:

expect.any(String)
expect.any(Number)
expect.any(Date)
expect.any(Function)
expect.anything()  // Any non-null, non-undefined value
expect.stringMatching(/pattern/)
expect.arrayContaining([1, 2])
expect.objectContaining({ key: 'value' })

Snapshot Serializers

Customize how values are serialized:

// vitest.config.ts or jest.config.js
export default {
  snapshotSerializers: ['./test/serializers/dateSerializer.ts'],
};
// test/serializers/dateSerializer.ts
export function serialize(val: Date): string {
  return `Date<${val.toISOString()}>`;
}

export function test(val: unknown): val is Date {
  return val instanceof Date;
}

Built-in serializers handle:

  • React elements (via @testing-library/react)
  • DOM elements
  • Plain objects and arrays

Updating Snapshots

Interactive Update

In watch mode, press u to update failing snapshots:

$ pnpm test

 FAIL  src/config.test.ts
  ● generates production config

    expect(received).toMatchSnapshot()

    Snapshot name: `generates production config 1`

    - Snapshot
    + Received

    - "debug": false,
    + "debug": true,

Watch Usage: Press u to update failing snapshots.

Update All Snapshots

# Vitest
pnpm test -- -u
pnpm test -- --update

# Jest
pnpm test -- -u
pnpm test -- --updateSnapshot

Warning: Review every change before updating. Don't blindly update—the diff might reveal a bug.

Update Specific Snapshots

# Update only tests matching pattern
pnpm test -- -u --testNamePattern="config"

# Update only specific file
pnpm test -- -u src/config.test.ts

Reviewing Snapshot Changes

Treat snapshot diffs like code changes in review:

Questions to Ask

  1. Is this change intentional? Does it match the PR's purpose?
  2. Is the new output correct? Not just "different"—actually right?
  3. Are we testing the right thing? Maybe the snapshot shouldn't exist?

Red Flags

- "version": "1.0.0",
+ "version": "1.0.1",

Should version be in the snapshot? Probably not.

  {
    "name": "John",
+   "email": "[email protected]",
+   "phone": "555-1234",
+   "address": {
+     "street": "123 Main St",
+     ...

Snapshot growing significantly—consider if it's still useful.

Snapshot name: `component renders correctly 1`

Vague name—what's being tested? Name snapshots meaningfully:

it('renders error state with retry button', () => {
  // Snapshot name will be: "renders error state with retry button 1"
});

Snapshot Testing in CI

Preventing Accidental Updates

Ensure CI fails on outdated snapshots:

# GitHub Actions
- name: Run tests
  run: pnpm test -- --ci
  # --ci flag prevents interactive mode and accidental updates

Catching Missing Snapshots

Fail if new tests don't have snapshots committed:

# Vitest
pnpm test -- --ci

# Jest (explicit)
pnpm test -- --ci --bail

Snapshot Files in Git

Always commit .snap files. They're part of your test suite:

# Don't ignore snapshots
# !**/__snapshots__/  ← Don't do this

Review snapshot changes in PRs like any other code.

When Snapshots Become Burdensome

Signs your snapshots need attention:

Constant Updates

If you update snapshots on every PR:

  • Snapshots are too large or volatile
  • Consider targeted assertions instead

Rubber-Stamp Reviews

If reviewers click "update" without reading:

  • Snapshots are too large to review
  • Break into smaller, focused snapshots

Test Failures You Ignore

If failing snapshots are noise:

  • Remove them
  • Tests that cry wolf are worse than no tests

Refactoring Fear

If you avoid refactoring because of snapshot updates:

  • Snapshots are testing implementation, not behavior
  • Replace with behavioral tests

Snapshot Testing Checklist

Before adding a snapshot test, ask:

  • Is the output serializable and deterministic?
  • Is the snapshot small enough to review meaningfully?
  • Will this output remain stable across routine changes?
  • Is a behavioral test not a better fit?
  • Does the test name describe what's being captured?

When updating snapshots:

  • Did I review every line of the diff?
  • Is the new output correct (not just different)?
  • Does the change match the PR's intent?
  • Are there unintended changes mixed in?

Alternative Approaches

Sometimes other testing strategies work better:

Property-Based Assertions

// Instead of snapshot
expect(user).toMatchSnapshot();

// Assert properties
expect(user).toMatchObject({
  name: expect.any(String),
  email: expect.stringMatching(/@/),
  age: expect.any(Number),
});

Schema Validation

import { z } from 'zod';

const userSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1),
  email: z.string().email(),
});

it('creates valid user', () => {
  const user = createUser({ name: 'John', email: '[email protected]' });
  expect(() => userSchema.parse(user)).not.toThrow();
});

Visual Regression Testing

For UI, consider dedicated visual testing:

// Playwright
await expect(page).toHaveScreenshot('dashboard.png');

// Storybook + Chromatic
// Automated visual regression in CI

See Also

Last updated: March 23, 2026