Documentation Next.js

はじめに

スナップショットテストは、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を実行しない
  • コードレビューで確認: スナップショットの変更も必ずレビュー
  • 行動テストと併用: スナップショットだけでなく、インタラクションテストも実施

参考文献

円