Understanding Snapshot Testing
Snapshot testing is a testing technique that captures the output of a component or function and saves it to a file. On subsequent test runs, the current output is compared against the saved snapshot. If they differ, the test fails. Snapshot testing is particularly useful for testing UI components, API responses, and data transformations where you want to detect unexpected changes.
Key Concept: Snapshot testing doesn't verify that output is correct, only that it hasn't changed. It's a regression detection tool, not a correctness validator. You must manually review snapshots to ensure they represent the desired output.
When to Use Snapshot Testing
Snapshot testing is ideal for specific scenarios:
Good Use Cases:
- UI Components: React/Vue components with consistent markup
- CLI Output: Command-line tool output formatting
- API Responses: Structured JSON/XML responses
- Configuration Files: Generated config output
- Error Messages: Formatted error strings
- HTML Email Templates: Email rendering output
Poor Use Cases:
- Dynamic Data: Timestamps, random IDs, dates
- Business Logic: Calculations and algorithms
- User Interactions: Click handlers, form submissions
- External API Calls: Third-party service responses
- Performance Tests: Timing-dependent operations
Common Mistake: Using snapshot testing as a substitute for proper assertions. Snapshots complement traditional testing but shouldn't replace explicit assertions about behavior. Always ask: "Am I testing that something works, or just that it hasn't changed?"
Jest Snapshot Testing
Jest has excellent built-in support for snapshot testing. Here's how to use it effectively:
Basic Snapshot Test
// components/UserCard.jsx
function UserCard({ name, email, role }) {
return (
<div className="user-card">
<h2>{name}</h2>
<p>{email}</p>
<span className={`role role-${role}`}>{role}</span>
</div>
);
}
// components/__tests__/UserCard.test.jsx
import { render } from '@testing-library/react';
import UserCard from '../UserCard';
test('renders user card correctly', () => {
const { container } = render(
<UserCard
name="John Doe"
email="john@example.com"
role="admin"
/>
);
expect(container).toMatchSnapshot();
});
When this test runs for the first time, Jest creates a snapshot file:
// components/__tests__/__snapshots__/UserCard.test.jsx.snap
exports[`renders user card correctly 1`] = `
<div>
<div
class="user-card"
>
<h2>
John Doe
</h2>
<p>
john@example.com
</p>
<span
class="role role-admin"
>
admin
</span>
</div>
</div>
`;
Property Matchers for Dynamic Data
Use property matchers to handle dynamic values like dates and IDs:
test('creates order with timestamp', () => {
const order = createOrder({
userId: 123,
items: [{ id: 1, quantity: 2 }]
});
expect(order).toMatchSnapshot({
id: expect.any(String), // Ignore specific ID
createdAt: expect.any(Date), // Ignore timestamp
userId: 123, // Keep known values
items: [
{
id: 1,
quantity: 2,
addedAt: expect.any(Date) // Ignore nested dynamic data
}
]
});
});
The snapshot will store:
exports[`creates order with timestamp 1`] = `
Object {
"createdAt": Any<Date>,
"id": Any<String>,
"items": Array [
Object {
"addedAt": Any<Date>,
"id": 1,
"quantity": 2,
},
],
"userId": 123,
}
`;
Inline Snapshots
Inline snapshots store the snapshot directly in the test file instead of a separate file:
test('formats price correctly', () => {
const result = formatPrice(1234.56, 'USD');
expect(result).toMatchInlineSnapshot(`"$1,234.56"`);
});
test('generates error message', () => {
const error = new ValidationError('Invalid email', 'email');
expect(error.toJSON()).toMatchInlineSnapshot(`
Object {
"field": "email",
"message": "Invalid email",
"type": "validation_error",
}
`);
});
Inline vs. External Snapshots:
- Inline: Best for small snapshots (1-10 lines) that benefit from proximity to the test. Easy to review during code reviews.
- External: Better for large snapshots (UI components, API responses) that would clutter test files. Keeps tests readable.
Benefits of Inline Snapshots
- Snapshots are visible in code reviews alongside tests
- No need to switch between test and snapshot files
- Easier to understand test intent
- Better for small, focused snapshots
Updating Snapshots
When component output legitimately changes, you need to update snapshots:
Update All Snapshots
# Update all failed snapshots
npm test -- --updateSnapshot
# or
npm test -- -u
# Watch mode - press 'u' to update all
npm test -- --watch
Update Specific Snapshots (Interactive Mode)
# Run in interactive mode
npm test -- --watch
# When tests fail:
# Press 'i' to enter interactive update mode
# Review each snapshot change
# Press 'u' to update, 's' to skip
Update Single Test File
# Update snapshots for specific file
npm test UserCard.test.jsx -- -u
# Update specific test
npm test -- -t "renders user card correctly" -u
Snapshot Update Discipline: Never blindly update snapshots with
-u without reviewing changes. Always:
- Run tests to see what changed
- Review the diff in the snapshot file or terminal
- Verify the change is intentional
- Update only if the new output is correct
- Commit snapshot updates with the code that caused them
Treating snapshots as "green button" approvals defeats their purpose.
Snapshot Testing Best Practices
1. Keep Snapshots Small and Focused
// BAD: Snapshot entire page
test('dashboard page', () => {
const { container } = render(<DashboardPage />);
expect(container).toMatchSnapshot(); // Too broad!
});
// GOOD: Snapshot individual components
test('dashboard header', () => {
const { container } = render(<DashboardHeader user={mockUser} />);
expect(container).toMatchSnapshot();
});
test('dashboard stats widget', () => {
const { container } = render(<StatsWidget data={mockData} />);
expect(container).toMatchSnapshot();
});
2. Snapshot Data, Not Implementation
// BAD: Snapshotting internal structure
test('user service', () => {
const service = new UserService();
expect(service).toMatchSnapshot(); // Exposes internals
});
// GOOD: Snapshot output/behavior
test('user service formats user data', () => {
const service = new UserService();
const result = service.formatUser(mockUser);
expect(result).toMatchSnapshot();
});
3. Combine with Traditional Assertions
test('generates invoice PDF', () => {
const invoice = generateInvoice(order);
// Explicit assertions for critical values
expect(invoice.total).toBe(150.00);
expect(invoice.tax).toBe(15.00);
expect(invoice.items).toHaveLength(3);
// Snapshot for overall structure
expect(invoice).toMatchSnapshot({
id: expect.any(String),
date: expect.any(Date)
});
});
4. Use Descriptive Test Names
// BAD: Generic name
test('button', () => {
expect(<Button />).toMatchSnapshot();
});
// GOOD: Descriptive name
test('primary button with icon and disabled state', () => {
const { container } = render(
<Button variant="primary" icon="check" disabled />
);
expect(container).toMatchSnapshot();
});
Snapshot Testing in Different Scenarios
React Component Snapshots
import { render } from '@testing-library/react';
import { ProductCard } from '../ProductCard';
describe('ProductCard', () => {
const mockProduct = {
id: 1,
name: 'Laptop',
price: 999.99,
inStock: true
};
test('renders in stock product', () => {
const { container } = render(<ProductCard product={mockProduct} />);
expect(container.firstChild).toMatchSnapshot();
});
test('renders out of stock product', () => {
const { container } = render(
<ProductCard product={{ ...mockProduct, inStock: false }} />
);
expect(container.firstChild).toMatchSnapshot();
});
test('renders with discount', () => {
const { container } = render(
<ProductCard product={mockProduct} discount={20} />
);
expect(container.firstChild).toMatchSnapshot();
});
});
API Response Snapshots
test('formats API error response', () => {
const error = new APIError('Not found', 404);
const response = error.toResponse();
expect(response).toMatchInlineSnapshot(`
Object {
"error": Object {
"code": 404,
"message": "Not found",
"type": "not_found",
},
"success": false,
}
`);
});
test('paginates user list', async () => {
const result = await userService.list({ page: 1, limit: 10 });
expect(result).toMatchSnapshot({
data: expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
createdAt: expect.any(String)
})
]),
meta: {
currentPage: 1,
totalPages: expect.any(Number),
total: expect.any(Number)
}
});
});
CLI Output Snapshots
import { formatTable } from '../cli-formatter';
test('formats data table', () => {
const data = [
{ name: 'Alice', age: 30, role: 'Admin' },
{ name: 'Bob', age: 25, role: 'User' }
];
const output = formatTable(data);
expect(output).toMatchInlineSnapshot(`
"┌───────┬─────┬───────┐
│ Name │ Age │ Role │
├───────┼─────┼───────┤
│ Alice │ 30 │ Admin │
│ Bob │ 25 │ User │
└───────┴─────┴───────┘"
`);
});
Snapshot Testing in PHP
While PHP doesn't have built-in snapshot testing like Jest, libraries like spatie/phpunit-snapshot-assertions provide similar functionality:
Installation and Setup
# Install package
composer require --dev spatie/phpunit-snapshot-assertions
# Use in tests
use Spatie\Snapshots\MatchesSnapshots;
class OrderTest extends TestCase
{
use MatchesSnapshots;
public function test_formats_order_summary()
{
$order = Order::factory()->create();
$summary = $order->formatSummary();
$this->assertMatchesSnapshot($summary);
}
public function test_generates_invoice_html()
{
$invoice = Invoice::generate($this->order);
$this->assertMatchesHtmlSnapshot($invoice->toHtml());
}
public function test_exports_user_to_json()
{
$user = User::factory()->create();
$this->assertMatchesJsonSnapshot($user->toArray());
}
}
Updating PHP Snapshots
# Update all snapshots
./vendor/bin/phpunit -d --update-snapshots
# Update specific test
./vendor/bin/phpunit --filter test_formats_order_summary -d --update-snapshots
When NOT to Use Snapshot Testing
Avoid snapshots for:
- Changing Data: Current timestamps, random values, session IDs
- Large Objects: Entire database dumps, complete page HTML (too brittle)
- Behavior Testing: Does clicking a button call the right function?
- Calculations: Math operations should use explicit assertions
- Third-party Libraries: Don't snapshot library output you don't control
Better Alternatives
// BAD: Snapshot for calculation
test('calculates total', () => {
const result = calculateTotal([10, 20, 30]);
expect(result).toMatchInlineSnapshot(`60`);
});
// GOOD: Explicit assertion
test('calculates total', () => {
const result = calculateTotal([10, 20, 30]);
expect(result).toBe(60);
});
// BAD: Snapshot dynamic date
test('creates timestamp', () => {
expect(getCurrentTimestamp()).toMatchSnapshot();
});
// GOOD: Test date properties
test('creates timestamp', () => {
const timestamp = getCurrentTimestamp();
expect(timestamp).toBeInstanceOf(Date);
expect(timestamp.getTime()).toBeGreaterThan(Date.now() - 1000);
});
Practical Exercise
Exercise 1: Snapshot test a notification component
// components/Notification.jsx
function Notification({ type, message, dismissible, onDismiss }) {
return (
<div className={`notification notification-${type}`}>
<span className="notification-message">{message}</span>
{dismissible && (
<button onClick={onDismiss} className="notification-close">
×
</button>
)}
</div>
);
}
// TODO: Write snapshot tests for:
// 1. Success notification (not dismissible)
// 2. Error notification (dismissible)
// 3. Warning notification (dismissible)
// 4. Info notification (not dismissible)
Exercise 2: Create API response snapshot tests
// services/api.js
class APIClient {
formatSuccessResponse(data, meta = {}) {
return {
success: true,
data,
meta: {
timestamp: new Date(),
...meta
}
};
}
formatErrorResponse(message, code, details = null) {
return {
success: false,
error: {
message,
code,
details,
timestamp: new Date()
}
};
}
}
// TODO: Write snapshot tests with property matchers for:
// 1. Success response with user data
// 2. Success response with pagination meta
// 3. Error response (404)
// 4. Error response with validation details
Exercise 3: Test a data formatter with snapshots
// formatters/report.js
function formatSalesReport(sales) {
const total = sales.reduce((sum, s) => sum + s.amount, 0);
const average = total / sales.length;
return `
Sales Report
============
Total Sales: ${sales.length}
Revenue: $${total.toFixed(2)}
Average: $${average.toFixed(2)}
Top Products:
${sales
.sort((a, b) => b.amount - a.amount)
.slice(0, 3)
.map((s, i) => `${i + 1}. ${s.product} - $${s.amount}`)
.join('\n')}
`.trim();
}
// TODO: Create snapshot test with mock sales data
// Verify report formatting is consistent