はじめに
スナップショットテストは、UIコンポーネントの予期しない変更を検知するための効果的な手法です。この記事では、Next.jsプロジェクトでJestとReact Testing Libraryを使用したスナップショットテストの実装方法を解説します。
セットアップ
パッケージのインストール
npm install -D jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom ts-jest @types/jest
Jest設定ファイル
// jest.config.js
const nextJest = require('next/jest');
const createJestConfig = nextJest({
dir: './',
});
const customJestConfig = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testEnvironment: 'jest-environment-jsdom',
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
testPathIgnorePatterns: ['<rootDir>/node_modules/', '<rootDir>/.next/'],
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.d.ts',
'!src/**/*.stories.{js,jsx,ts,tsx}',
],
};
module.exports = createJestConfig(customJestConfig);
// jest.setup.ts
import '@testing-library/jest-dom';
// グローバルモックの設定
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
// Next.js Image コンポーネントのモック
jest.mock('next/image', () => ({
__esModule: true,
default: (props: any) => {
// eslint-disable-next-line @next/next/no-img-element
return <img {...props} alt={props.alt} />;
},
}));
// Next.js Link コンポーネントのモック
jest.mock('next/link', () => ({
__esModule: true,
default: ({ children, href }: any) => <a href={href}>{children}</a>,
}));
// package.json
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:update": "jest --updateSnapshot"
}
}
基本的なスナップショットテスト
シンプルなコンポーネント
// components/Button.tsx
interface ButtonProps {
children: React.ReactNode;
variant?: 'primary' | 'secondary' | 'danger';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
onClick?: () => void;
}
export function Button({
children,
variant = 'primary',
size = 'md',
disabled = false,
onClick,
}: ButtonProps) {
return (
<button
className={`btn btn-${variant} btn-${size}`}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
);
}
// __tests__/components/Button.test.tsx
import { render } from '@testing-library/react';
import { Button } from '@/components/Button';
describe('Button', () => {
it('should match snapshot with default props', () => {
const { container } = render(<Button>Click me</Button>);
expect(container).toMatchSnapshot();
});
it('should match snapshot with primary variant', () => {
const { container } = render(
<Button variant="primary">Primary</Button>
);
expect(container).toMatchSnapshot();
});
it('should match snapshot with secondary variant', () => {
const { container } = render(
<Button variant="secondary">Secondary</Button>
);
expect(container).toMatchSnapshot();
});
it('should match snapshot with danger variant', () => {
const { container } = render(
<Button variant="danger">Danger</Button>
);
expect(container).toMatchSnapshot();
});
it('should match snapshot when disabled', () => {
const { container } = render(
<Button disabled>Disabled</Button>
);
expect(container).toMatchSnapshot();
});
it('should match snapshot with all sizes', () => {
const sizes = ['sm', 'md', 'lg'] as const;
sizes.forEach((size) => {
const { container } = render(
<Button size={size}>Size {size}</Button>
);
expect(container).toMatchSnapshot();
});
});
});
インラインスナップショット
// __tests__/components/Badge.test.tsx
import { render } from '@testing-library/react';
import { Badge } from '@/components/Badge';
describe('Badge', () => {
it('should render correctly', () => {
const { container } = render(<Badge>New</Badge>);
// インラインスナップショット
expect(container.firstChild).toMatchInlineSnapshot(`
<span
class="badge badge-default"
>
New
</span>
`);
});
it('should render with success variant', () => {
const { container } = render(<Badge variant="success">Success</Badge>);
expect(container.firstChild).toMatchInlineSnapshot(`
<span
class="badge badge-success"
>
Success
</span>
`);
});
});
複雑なコンポーネントのテスト
カードコンポーネント
// components/Card.tsx
interface CardProps {
title: string;
description: string;
image?: string;
tags?: string[];
author?: {
name: string;
avatar: string;
};
publishedAt?: Date;
}
export function Card({
title,
description,
image,
tags,
author,
publishedAt,
}: CardProps) {
return (
<article className="card">
{image && (
<div className="card-image">
<img src={image} alt={title} />
</div>
)}
<div className="card-content">
<h2 className="card-title">{title}</h2>
<p className="card-description">{description}</p>
{tags && tags.length > 0 && (
<div className="card-tags">
{tags.map((tag) => (
<span key={tag} className="tag">
{tag}
</span>
))}
</div>
)}
{author && (
<div className="card-author">
<img src={author.avatar} alt={author.name} />
<span>{author.name}</span>
</div>
)}
{publishedAt && (
<time className="card-date">
{publishedAt.toLocaleDateString('ja-JP')}
</time>
)}
</div>
</article>
);
}
// __tests__/components/Card.test.tsx
import { render } from '@testing-library/react';
import { Card } from '@/components/Card';
describe('Card', () => {
const baseProps = {
title: 'Test Title',
description: 'Test Description',
};
it('should match snapshot with minimal props', () => {
const { container } = render(<Card {...baseProps} />);
expect(container).toMatchSnapshot();
});
it('should match snapshot with image', () => {
const { container } = render(
<Card {...baseProps} image="/test-image.jpg" />
);
expect(container).toMatchSnapshot();
});
it('should match snapshot with tags', () => {
const { container } = render(
<Card {...baseProps} tags={['React', 'Next.js', 'TypeScript']} />
);
expect(container).toMatchSnapshot();
});
it('should match snapshot with author', () => {
const { container } = render(
<Card
{...baseProps}
author={{
name: 'John Doe',
avatar: '/avatar.jpg',
}}
/>
);
expect(container).toMatchSnapshot();
});
it('should match snapshot with all props', () => {
const { container } = render(
<Card
title="Complete Card"
description="This is a complete card with all props"
image="/hero.jpg"
tags={['React', 'Next.js']}
author={{
name: 'Jane Smith',
avatar: '/jane.jpg',
}}
publishedAt={new Date('2024-01-15')}
/>
);
expect(container).toMatchSnapshot();
});
});
動的コンテンツのテスト
日付やランダム値の処理
// __tests__/components/Timestamp.test.tsx
import { render } from '@testing-library/react';
import { Timestamp } from '@/components/Timestamp';
describe('Timestamp', () => {
// 日付をモック
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T10:00:00Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should match snapshot with fixed date', () => {
const { container } = render(<Timestamp />);
expect(container).toMatchSnapshot();
});
it('should match snapshot with specific date', () => {
const { container } = render(
<Timestamp date={new Date('2024-06-01T12:00:00Z')} />
);
expect(container).toMatchSnapshot();
});
});
UUIDやランダムIDの処理
// __tests__/components/ListItem.test.tsx
import { render } from '@testing-library/react';
import { ListItem } from '@/components/ListItem';
// UUIDをモック
jest.mock('uuid', () => ({
v4: jest.fn(() => 'mocked-uuid-1234'),
}));
describe('ListItem', () => {
it('should match snapshot with stable ID', () => {
const { container } = render(<ListItem text="Test Item" />);
expect(container).toMatchSnapshot();
});
});
カスタムスナップショットシリアライザー
不要な属性を除外
// jest.setup.ts
import { addSerializer } from 'jest-specific-snapshot';
// data-testid属性を除外するシリアライザー
expect.addSnapshotSerializer({
test: (val) => {
return (
val &&
typeof val === 'object' &&
'attributes' in val &&
'data-testid' in val.attributes
);
},
serialize: (val, config, indentation, depth, refs, printer) => {
const { 'data-testid': _, ...rest } = val.attributes;
return printer({ ...val, attributes: rest }, config, indentation, depth, refs);
},
});
// カスタムシリアライザーでクラス名をソート
expect.addSnapshotSerializer({
test: (val) => typeof val === 'string' && val.includes('class="'),
serialize: (val: string) => {
return val.replace(/class="([^"]+)"/g, (match, classes) => {
const sortedClasses = classes.split(' ').sort().join(' ');
return `class="${sortedClasses}"`;
});
},
});
スナップショットテストのベストプラクティス
テストの整理
// __tests__/components/Modal.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Modal } from '@/components/Modal';
describe('Modal', () => {
const defaultProps = {
isOpen: true,
onClose: jest.fn(),
title: 'Test Modal',
children: <p>Modal Content</p>,
};
describe('snapshots', () => {
it('should match snapshot when open', () => {
const { baseElement } = render(<Modal {...defaultProps} />);
expect(baseElement).toMatchSnapshot();
});
it('should match snapshot when closed', () => {
const { baseElement } = render(
<Modal {...defaultProps} isOpen={false} />
);
expect(baseElement).toMatchSnapshot();
});
it('should match snapshot with custom footer', () => {
const { baseElement } = render(
<Modal
{...defaultProps}
footer={
<div>
<button>Cancel</button>
<button>Confirm</button>
</div>
}
/>
);
expect(baseElement).toMatchSnapshot();
});
});
describe('behavior', () => {
it('should call onClose when clicking backdrop', async () => {
const user = userEvent.setup();
const onClose = jest.fn();
render(<Modal {...defaultProps} onClose={onClose} />);
await user.click(screen.getByTestId('modal-backdrop'));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('should call onClose when pressing Escape', async () => {
const user = userEvent.setup();
const onClose = jest.fn();
render(<Modal {...defaultProps} onClose={onClose} />);
await user.keyboard('{Escape}');
expect(onClose).toHaveBeenCalledTimes(1);
});
});
});
スナップショットファイルの管理
__tests__/
├── components/
│ ├── Button.test.tsx
│ ├── Card.test.tsx
│ └── __snapshots__/
│ ├── Button.test.tsx.snap
│ └── Card.test.tsx.snap
CI/CDでの設定
# .github/workflows/test.yml
name: Test
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test -- --ci --coverage
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
よくある問題と解決策
スナップショットが大きすぎる
// ❌ 悪い例:全体をスナップショット
it('should render page', () => {
const { container } = render(<LargePage />);
expect(container).toMatchSnapshot(); // 巨大なスナップショット
});
// ✅ 良い例:特定の部分のみスナップショット
it('should render header correctly', () => {
render(<LargePage />);
expect(screen.getByRole('banner')).toMatchSnapshot();
});
it('should render navigation correctly', () => {
render(<LargePage />);
expect(screen.getByRole('navigation')).toMatchSnapshot();
});
不安定なスナップショット
// 動的な値を固定化
jest.mock('@/lib/utils', () => ({
generateId: () => 'fixed-id',
formatDate: (date: Date) => '2024-01-15',
}));
まとめ
スナップショットテストの活用ポイントをまとめます。
- 小さなコンポーネント単位: 大きなコンポーネントは分割してテスト
- 動的な値のモック: 日付やIDは固定値を使用
- 意図的な変更のみ更新: 不用意に
--updateSnapshotを実行しない - コードレビューで確認: スナップショットの変更も必ずレビュー
- 行動テストと併用: スナップショットだけでなく、インタラクションテストも実施