Testing & TDD

Snapshot Testing

28 min Lesson 17 of 35

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:
  1. Run tests to see what changed
  2. Review the diff in the snapshot file or terminal
  3. Verify the change is intentional
  4. Update only if the new output is correct
  5. 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