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
- First run: Test captures output and saves it to a
.snapfile - Subsequent runs: Test compares current output against stored snapshot
- Difference detected: Test fails, showing the diff
- 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
.snapfile 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
- Is this change intentional? Does it match the PR's purpose?
- Is the new output correct? Not just "different"—actually right?
- 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
- Testing Overview - Testing philosophy and strategies
- Vitest - Vitest snapshot documentation
- Jest - Jest snapshot documentation
- Playwright - Visual regression testing
- Effective Snapshot Testing - Kent C. Dodds' guide