Snapshot Testing Pattern

Use snapshot testing effectively for UI components, data structures, and generated output with Vitest inline and file snapshots.

Overview#

Snapshot testing captures the output of code and compares it against a stored reference. It's useful for detecting unintended changes to UI components and data transformations.

When to use:

  • Testing component render output
  • Verifying serialized data structures
  • Testing generated code or configurations
  • Catching unintended UI changes

Key features:

  • Automatic snapshot creation
  • Inline and file-based snapshots
  • Property matchers for dynamic values
  • Easy snapshot updates

Code Example#

Basic Snapshot#

1// tests/components/Button.test.tsx 2import { render } from '@testing-library/react' 3import { expect, it } from 'vitest' 4import { Button } from '@/components/ui/Button' 5 6it('renders correctly', () => { 7 const { container } = render(<Button>Click me</Button>) 8 expect(container).toMatchSnapshot() 9}) 10 11it('renders loading state', () => { 12 const { container } = render(<Button loading>Submit</Button>) 13 expect(container).toMatchSnapshot() 14}) 15 16it('renders disabled state', () => { 17 const { container } = render(<Button disabled>Disabled</Button>) 18 expect(container).toMatchSnapshot() 19})

Inline Snapshots#

1// More readable for small outputs 2import { expect, it } from 'vitest' 3import { formatCurrency, formatDate, getDisplayName } from '@/lib/utils' 4 5it('formats currency', () => { 6 expect(formatCurrency(1234.56)).toMatchInlineSnapshot(`"$1,234.56"`) 7}) 8 9it('formats date', () => { 10 expect(formatDate(new Date('2024-01-15'))).toMatchInlineSnapshot( 11 `"January 15, 2024"` 12 ) 13}) 14 15it('generates user display name', () => { 16 expect(getDisplayName({ firstName: 'John', lastName: 'Doe' })) 17 .toMatchInlineSnapshot(`"John Doe"`) 18})

Object Snapshots#

1// Snapshot complex objects 2import { expect, it } from 'vitest' 3import { transformResponse, buildConfig } from '@/lib/api' 4 5it('transforms API response', () => { 6 const response = { 7 id: '123', 8 created_at: '2024-01-01T00:00:00Z', 9 user_name: 'johndoe', 10 is_active: true 11 } 12 13 expect(transformResponse(response)).toMatchInlineSnapshot(` 14 { 15 "id": "123", 16 "createdAt": "2024-01-01T00:00:00.000Z", 17 "userName": "johndoe", 18 "isActive": true 19 } 20 `) 21}) 22 23it('builds configuration object', () => { 24 const config = buildConfig({ 25 name: 'my-app', 26 features: ['auth', 'api'] 27 }) 28 29 expect(config).toMatchInlineSnapshot(` 30 { 31 "name": "my-app", 32 "features": [ 33 "auth", 34 "api" 35 ], 36 "version": "1.0.0", 37 "debug": false 38 } 39 `) 40})

Property Matchers#

1// Ignore dynamic values 2import { expect, it } from 'vitest' 3 4it('creates user with generated id', async () => { 5 const user = await createUser({ email: 'test@example.com' }) 6 7 expect(user).toMatchSnapshot({ 8 id: expect.any(String), 9 createdAt: expect.any(Date) 10 }) 11}) 12 13it('generates order with timestamp', () => { 14 const order = createOrder({ items: [{ id: '1', quantity: 2 }] }) 15 16 expect(order).toMatchSnapshot({ 17 id: expect.stringMatching(/^order_/), 18 createdAt: expect.any(Date), 19 updatedAt: expect.any(Date), 20 items: [ 21 { 22 id: '1', 23 quantity: 2, 24 addedAt: expect.any(Date) 25 } 26 ] 27 }) 28}) 29 30// Snapshot with asymmetric matchers 31it('returns paginated response', async () => { 32 const response = await fetchUsers({ page: 1, limit: 10 }) 33 34 expect(response).toMatchSnapshot({ 35 data: expect.arrayContaining([ 36 expect.objectContaining({ 37 id: expect.any(String), 38 email: expect.stringMatching(/@/), 39 createdAt: expect.any(String) 40 }) 41 ]), 42 pagination: { 43 page: 1, 44 limit: 10, 45 total: expect.any(Number), 46 totalPages: expect.any(Number) 47 } 48 }) 49})

File Snapshots#

1// Large output to separate files 2import { expect, it } from 'vitest' 3 4it('generates config file', () => { 5 const config = generateConfig({ 6 name: 'my-app', 7 features: ['auth', 'api', 'db'] 8 }) 9 10 expect(config).toMatchFileSnapshot('./snapshots/config.json') 11}) 12 13it('generates schema', () => { 14 const schema = generateSchema(models) 15 expect(schema).toMatchFileSnapshot('./snapshots/schema.graphql') 16})

Custom Serializers#

1// vitest.config.ts 2export default defineConfig({ 3 test: { 4 snapshotSerializers: ['./tests/serializers/date.ts'] 5 } 6}) 7 8// tests/serializers/date.ts 9export const serialize = (val: Date) => `Date<${val.toISOString()}>` 10export const test = (val: unknown) => val instanceof Date 11 12// Output in snapshots: 13// Date<2024-01-01T00:00:00.000Z> 14 15// Custom component serializer 16// tests/serializers/component.ts 17export const serialize = (val: React.ReactElement) => { 18 // Custom serialization logic 19 return prettyPrint(val) 20} 21 22export const test = (val: unknown) => { 23 return val && typeof val === 'object' && '$$typeof' in val 24}

Component Variant Snapshots#

1// Snapshot multiple variants 2import { render } from '@testing-library/react' 3import { describe, it, expect } from 'vitest' 4import { Button } from '@/components/ui/Button' 5 6describe('Button', () => { 7 const variants = ['primary', 'secondary', 'danger', 'ghost'] as const 8 const sizes = ['sm', 'md', 'lg'] as const 9 10 // Test all variant and size combinations 11 variants.forEach(variant => { 12 sizes.forEach(size => { 13 it(`renders ${variant} ${size}`, () => { 14 const { container } = render( 15 <Button variant={variant} size={size}> 16 Button 17 </Button> 18 ) 19 expect(container.firstChild).toMatchSnapshot() 20 }) 21 }) 22 }) 23 24 // Test with different states 25 const states = [ 26 { disabled: true }, 27 { loading: true }, 28 { disabled: false, loading: false } 29 ] 30 31 states.forEach(state => { 32 it(`renders with state ${JSON.stringify(state)}`, () => { 33 const { container } = render( 34 <Button {...state}>Button</Button> 35 ) 36 expect(container.firstChild).toMatchSnapshot() 37 }) 38 }) 39})

Update Snapshots#

1# Update all snapshots 2npm test -- --update 3 4# Update specific test file snapshots 5npm test -- Button.test.tsx --update 6 7# Interactive update (choose which to update) 8npm test -- --watch 9# Then press 'u' to update 10 11# Update only failing snapshots 12npm test -- --update --reporter=verbose

Snapshot Best Practices Examples#

1// DON'T: Snapshot too much 2it('renders page', () => { 3 const { container } = render(<ComplexPage />) 4 expect(container).toMatchSnapshot() // Too large, hard to review 5}) 6 7// DO: Snapshot specific elements 8it('renders header correctly', () => { 9 render(<ComplexPage />) 10 expect(screen.getByRole('banner')).toMatchSnapshot() 11}) 12 13// DO: Use inline snapshots for small outputs 14it('formats date', () => { 15 expect(formatDate(new Date('2024-01-15'))) 16 .toMatchInlineSnapshot(`"January 15, 2024"`) 17}) 18 19// DON'T: Include dynamic data 20it('renders user', () => { 21 const { container } = render(<User id={Math.random()} />) 22 expect(container).toMatchSnapshot() // Will always fail 23}) 24 25// DO: Use stable test data 26it('renders user', () => { 27 const { container } = render( 28 <User 29 id="test-id" 30 createdAt={new Date('2024-01-01')} 31 /> 32 ) 33 expect(container).toMatchSnapshot() 34}) 35 36// DON'T: Snapshot implementation details 37it('has correct state', () => { 38 const { result } = renderHook(() => useCounter()) 39 expect(result.current).toMatchSnapshot() // Internal state 40}) 41 42// DO: Snapshot observable behavior 43it('renders correct count', () => { 44 render(<Counter initialCount={5} />) 45 expect(screen.getByRole('spinbutton')).toMatchSnapshot() 46})

Snapshot Testing Strategy#

1// Use snapshots for UI regression 2describe('Card component', () => { 3 it('matches snapshot', () => { 4 const { container } = render( 5 <Card 6 title="Test Card" 7 description="Test description" 8 image="/test.jpg" 9 /> 10 ) 11 expect(container).toMatchSnapshot() 12 }) 13}) 14 15// Use regular assertions for behavior 16describe('Card component behavior', () => { 17 it('calls onClick when clicked', () => { 18 const onClick = vi.fn() 19 render(<Card onClick={onClick} />) 20 21 fireEvent.click(screen.getByRole('article')) 22 expect(onClick).toHaveBeenCalled() 23 }) 24 25 it('renders image with alt text', () => { 26 render(<Card image="/test.jpg" imageAlt="Test image" />) 27 expect(screen.getByAltText('Test image')).toBeInTheDocument() 28 }) 29})

Usage Instructions#

  1. Write test with toMatchSnapshot(): Vitest creates snapshot on first run
  2. Review snapshot files: Verify the captured output is correct
  3. Commit snapshots: Include snapshot files in version control
  4. Update when needed: Run with --update flag after intentional changes
  5. Review in PRs: Check snapshot changes carefully during code review

Best Practices#

  1. Keep snapshots small - Snapshot specific elements, not entire pages
  2. Use inline for small outputs - More readable in the test file
  3. Use property matchers - Handle dynamic values like IDs and dates
  4. Review snapshot changes - Don't blindly update failing snapshots
  5. Complement with assertions - Use snapshots alongside behavior tests
  6. Avoid snapshots for logic - Test business logic with assertions
  7. Commit snapshots - They're part of your test suite