T-CREATOR

Jest でスナップショットテストを始める方法

Jest でスナップショットテストを始める方法

フロントエンド開発において、UI の一貫性を保つことは品質向上の重要な要素です。しかし、コンポーネントの見た目や出力結果が期待通りかどうかを手動で確認するのは、時間がかかり見落としも発生しがちです。そんな課題を解決してくれるのが、Jest のスナップショットテスト機能です。

この記事では、Jest のスナップショットテストの基本概念から実践的な活用方法まで、段階的に学習できるよう詳しく解説いたします。初めてスナップショットテストに触れる方でも、自信を持って導入できるようになるでしょう。

スナップショットテストとは何か

スナップショットテストの基本概念

スナップショットテストは、コンポーネントやオブジェクトの出力結果を「スナップショット」として保存し、次回のテスト実行時にその結果と比較するテスト手法です。予期しない変更を検出することで、リグレッション(機能の退行)を防ぐことができます。

スナップショットの仕組み

Jest のスナップショットテストでは、以下の流れでテストが実行されます:

  1. 初回実行時: テスト対象の出力結果をスナップショットファイルに保存
  2. 以降の実行時: 現在の出力結果と保存されたスナップショットを比較
  3. 差分検出時: テストが失敗し、変更内容を詳細に表示
javascript// 基本的なスナップショットテストの例
import { render } from '@testing-library/react';
import Button from './Button';

describe('Button コンポーネント', () => {
  it('should match snapshot', () => {
    const { container } = render(
      <Button variant='primary'>クリック</Button>
    );

    expect(container.firstChild).toMatchSnapshot();
  });
});

このテストを初回実行すると、__snapshots__ ディレクトリにスナップショットファイルが作成されます。

スナップショットファイルの構造

作成されるスナップショットファイルは、以下のような形式で保存されます:

javascript// __snapshots__/Button.test.js.snap
exports[`Button コンポーネント should match snapshot 1`] = `
<button
  class="btn btn-primary"
  type="button"
>
  クリック
</button>
`;

スナップショットファイルの特徴

#特徴説明
1可読性人間が読みやすいテキスト形式で保存
2バージョン管理Git などでバージョン管理可能
3レビュー対応コードレビューで変更内容を確認可能
4自動生成Jest が自動的に生成・更新
5テスト名連動テスト名に基づいたキーで管理

従来のテスト手法との違い

従来の UI テストでは、個別の要素や属性を一つずつ検証する必要がありました。スナップショットテストは、全体の出力を一度に検証できる点で大きく異なります。

javascript// 従来のテスト手法
describe('Button コンポーネント(従来手法)', () => {
  it('should render correctly', () => {
    const { getByRole } = render(
      <Button variant='primary'>クリック</Button>
    );

    const button = getByRole('button');
    expect(button).toHaveTextContent('クリック');
    expect(button).toHaveClass('btn');
    expect(button).toHaveClass('btn-primary');
    expect(button).toHaveAttribute('type', 'button');
  });
});

// スナップショットテスト
describe('Button コンポーネント(スナップショット)', () => {
  it('should match snapshot', () => {
    const { container } = render(
      <Button variant='primary'>クリック</Button>
    );

    expect(container.firstChild).toMatchSnapshot();
  });
});

スナップショットテストが解決する課題

UI の一貫性維持の困難さ

フロントエンド開発では、コンポーネントの数が増えるにつれて、UI の一貫性を保つことが困難になります。小さな変更が予期しない場所に影響を与える場合があるためです。

大規模プロジェクトでの課題

javascript// 多数のコンポーネントを持つプロジェクトの例
const ComponentLibrary = {
  Button: React.lazy(() => import('./Button')),
  Card: React.lazy(() => import('./Card')),
  Modal: React.lazy(() => import('./Modal')),
  Form: React.lazy(() => import('./Form')),
  // ... 数十個以上のコンポーネント
};

// 共通スタイルの変更が全コンポーネントに影響
const globalStyles = {
  primaryColor: '#007bff',
  fontSize: '14px',
  borderRadius: '4px',
  // CSS変更が予期しない影響を与える可能性
};

リグレッションテストの重要性

スナップショットテストは、以下のようなリグレッションを効率的に検出できます:

  1. CSS クラス名の変更 - スタイリングへの影響
  2. HTML 構造の変更 - レイアウトの崩れ
  3. テキスト内容の予期しない変更 - コンテンツの整合性
  4. 属性値の変更 - アクセシビリティへの影響

手動テストの限界

従来の手動テストには、以下のような限界があります:

javascript// 手動で確認が必要な項目の例
const ManualTestChecklist = [
  '✓ ボタンの文字色が正しい',
  '✓ ホバー時のスタイルが適用される',
  '✓ フォントサイズが仕様通り',
  '✓ マージン・パディングが正確',
  '✓ レスポンシブ対応が機能する',
  '✓ ダークモード対応が正しい',
  // 数十項目以上の確認作業...
];

// スナップショットテストなら一発で検証
describe('Component Visual Tests', () => {
  const variants = ['primary', 'secondary', 'danger'];
  const sizes = ['small', 'medium', 'large'];

  variants.forEach((variant) => {
    sizes.forEach((size) => {
      it(`should render ${variant} ${size} button correctly`, () => {
        const { container } = render(
          <Button variant={variant} size={size}>
            テストボタン
          </Button>
        );
        expect(container.firstChild).toMatchSnapshot();
      });
    });
  });
});

開発効率の向上

スナップショットテストの導入により、以下の効率化が実現できます:

#効率化項目効果
1テスト作成時間個別検証コードの記述が不要
2変更検出速度自動的に全ての変更を瞬時に検出
3レビュー効率スナップショット差分で変更内容を確認
4バグ発見率見落としがちな細かい変更も検出
5回帰テスト工数自動化により手動確認作業を削減

基本的なスナップショットテストの作成

環境準備とセットアップ

スナップショットテストを始めるには、まず Jest の環境を整える必要があります。

必要パッケージのインストール

bash# React プロジェクトの場合
yarn add --dev jest @testing-library/react @testing-library/jest-dom

# Vue.js プロジェクトの場合
yarn add --dev jest @vue/test-utils vue-jest

# 基本的な JavaScript プロジェクトの場合
yarn add --dev jest

Jest 設定ファイルの作成

javascript// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
  moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx'],
  transform: {
    '^.+\\.(js|jsx|ts|tsx)$': 'babel-jest',
  },
  testMatch: [
    '<rootDir>/src/**/__tests__/**/*.(js|jsx|ts|tsx)',
    '<rootDir>/src/**/?(*.)(spec|test).(js|jsx|ts|tsx)',
  ],
  moduleNameMapping: {
    '\\.(css|less|scss|sass)$': 'identity-obj-proxy',
  },
};

最初のスナップショットテスト

シンプルな関数からスナップショットテストを始めてみましょう。

javascript// utils/formatter.js
export function formatUserInfo(user) {
  return {
    displayName: `${user.firstName} ${user.lastName}`,
    email: user.email.toLowerCase(),
    createdAt: new Date(user.createdAt).toLocaleDateString(
      'ja-JP'
    ),
    isActive: user.status === 'active',
  };
}

export function generateUserCard(user) {
  const formatted = formatUserInfo(user);
  return `
    <div class="user-card">
      <h3>${formatted.displayName}</h3>
      <p>Email: ${formatted.email}</p>
      <p>登録日: ${formatted.createdAt}</p>
      <span class="status ${
        formatted.isActive ? 'active' : 'inactive'
      }">
        ${
          formatted.isActive ? 'アクティブ' : '非アクティブ'
        }
      </span>
    </div>
  `.trim();
}
javascript// __tests__/formatter.test.js
import {
  formatUserInfo,
  generateUserCard,
} from '../utils/formatter';

describe('User Formatter', () => {
  const mockUser = {
    firstName: '太郎',
    lastName: '田中',
    email: 'TARO.TANAKA@EXAMPLE.COM',
    createdAt: '2023-01-15T09:30:00Z',
    status: 'active',
  };

  describe('formatUserInfo', () => {
    it('should format user info correctly', () => {
      const result = formatUserInfo(mockUser);
      expect(result).toMatchSnapshot();
    });

    it('should handle inactive user', () => {
      const inactiveUser = {
        ...mockUser,
        status: 'inactive',
      };
      const result = formatUserInfo(inactiveUser);
      expect(result).toMatchSnapshot();
    });
  });

  describe('generateUserCard', () => {
    it('should generate user card HTML', () => {
      const result = generateUserCard(mockUser);
      expect(result).toMatchSnapshot();
    });
  });
});

複数パターンのテスト

異なる条件でのスナップショットテストを効率的に作成できます。

javascript// コンポーネントの様々な状態をテスト
describe('Alert コンポーネント', () => {
  const alertTypes = [
    'success',
    'warning',
    'error',
    'info',
  ];
  const sizes = ['small', 'medium', 'large'];

  alertTypes.forEach((type) => {
    describe(`${type} アラート`, () => {
      sizes.forEach((size) => {
        it(`should render ${size} size correctly`, () => {
          const { container } = render(
            <Alert type={type} size={size}>
              これは{type}アラートです
            </Alert>
          );
          expect(container.firstChild).toMatchSnapshot();
        });
      });
    });
  });

  it('should render with custom icon', () => {
    const { container } = render(
      <Alert type='success' icon='custom-check'>
        カスタムアイコン付きアラート
      </Alert>
    );
    expect(container.firstChild).toMatchSnapshot();
  });
});

スナップショットテストの実行

テストを実行して、スナップショットファイルを確認してみましょう。

bash# テスト実行
yarn test

# スナップショット更新モードで実行
yarn test --updateSnapshot

# 特定のテストファイルのみ実行
yarn test formatter.test.js

実行すると、以下のようなスナップショットファイルが生成されます:

javascript// __snapshots__/formatter.test.js.snap
exports[
  `User Formatter formatUserInfo should format user info correctly 1`
] = `
{
  "createdAt": "2023/1/15",
  "displayName": "太郎 田中",
  "email": "taro.tanaka@example.com",
  "isActive": true,
}
`;

exports[
  `User Formatter generateUserCard should generate user card HTML 1`
] = `
"<div class=\\"user-card\\">
  <h3>太郎 田中</h3>
  <p>Email: taro.tanaka@example.com</p>
  <p>登録日: 2023/1/15</p>
  <span class=\\"status active\\">
    アクティブ
  </span>
</div>"
`;

React コンポーネントのスナップショットテスト

基本的な React コンポーネントのテスト

React コンポーネントのスナップショットテストは、UI の一貫性を保つ上で特に有効です。

javascript// components/ProductCard.jsx
import React from 'react';
import './ProductCard.css';

const ProductCard = ({
  product,
  onAddToCart,
  showDiscount = false,
}) => {
  const discountPrice = product.price * 0.8;

  return (
    <div className='product-card'>
      <div className='product-image'>
        <img
          src={product.imageUrl}
          alt={product.name}
          loading='lazy'
        />
        {showDiscount && (
          <div className='discount-badge'>20% OFF</div>
        )}
      </div>

      <div className='product-info'>
        <h3 className='product-name'>{product.name}</h3>
        <p className='product-description'>
          {product.description}
        </p>

        <div className='product-price'>
          {showDiscount ? (
            <>
              <span className='original-price'>
                ¥{product.price.toLocaleString()}
              </span>
              <span className='discount-price'>
                ¥
                {Math.floor(discountPrice).toLocaleString()}
              </span>
            </>
          ) : (
            <span className='current-price'>
              ¥{product.price.toLocaleString()}
            </span>
          )}
        </div>

        <button
          className='add-to-cart-btn'
          onClick={() => onAddToCart(product.id)}
          disabled={!product.inStock}
        >
          {product.inStock ? 'カートに追加' : '在庫切れ'}
        </button>
      </div>
    </div>
  );
};

export default ProductCard;
javascript// __tests__/ProductCard.test.jsx
import React from 'react';
import { render } from '@testing-library/react';
import ProductCard from '../components/ProductCard';

describe('ProductCard コンポーネント', () => {
  const mockProduct = {
    id: 1,
    name: 'ワイヤレスイヤホン',
    description: '高音質で長時間バッテリー持続',
    price: 15000,
    imageUrl: '/images/earphones.jpg',
    inStock: true,
  };

  const mockOnAddToCart = jest.fn();

  beforeEach(() => {
    mockOnAddToCart.mockClear();
  });

  it('should render product card with basic info', () => {
    const { container } = render(
      <ProductCard
        product={mockProduct}
        onAddToCart={mockOnAddToCart}
      />
    );

    expect(container.firstChild).toMatchSnapshot();
  });

  it('should render with discount badge', () => {
    const { container } = render(
      <ProductCard
        product={mockProduct}
        onAddToCart={mockOnAddToCart}
        showDiscount={true}
      />
    );

    expect(container.firstChild).toMatchSnapshot();
  });

  it('should render out of stock product', () => {
    const outOfStockProduct = {
      ...mockProduct,
      inStock: false,
    };

    const { container } = render(
      <ProductCard
        product={outOfStockProduct}
        onAddToCart={mockOnAddToCart}
      />
    );

    expect(container.firstChild).toMatchSnapshot();
  });
});

プロパティのモック化

動的な値や関数プロパティを含むコンポーネントをテストする際の注意点です。

javascript// components/UserProfile.jsx
import React, { useState } from 'react';

const UserProfile = ({ user, onEdit, onDelete }) => {
  const [isExpanded, setIsExpanded] = useState(false);

  return (
    <div className='user-profile'>
      <div className='user-header'>
        <img
          className='avatar'
          src={user.avatar}
          alt={`${user.name}のアバター`}
        />
        <div className='user-info'>
          <h2>{user.name}</h2>
          <p className='user-email'>{user.email}</p>
          <p className='user-role'>{user.role}</p>
        </div>
      </div>

      <div className='user-actions'>
        <button onClick={() => setIsExpanded(!isExpanded)}>
          {isExpanded ? '詳細を隠す' : '詳細を表示'}
        </button>
        <button onClick={() => onEdit(user.id)}>
          編集
        </button>
        <button
          onClick={() => onDelete(user.id)}
          className='danger-btn'
        >
          削除
        </button>
      </div>

      {isExpanded && (
        <div className='user-details'>
          <p>
            登録日:{' '}
            {new Date(user.createdAt).toLocaleDateString(
              'ja-JP'
            )}
          </p>
          <p>
            最終ログイン:{' '}
            {new Date(user.lastLogin).toLocaleDateString(
              'ja-JP'
            )}
          </p>
          <p>投稿数: {user.postCount}</p>
        </div>
      )}
    </div>
  );
};

export default UserProfile;
javascript// __tests__/UserProfile.test.jsx
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import UserProfile from '../components/UserProfile';

describe('UserProfile コンポーネント', () => {
  const mockUser = {
    id: 123,
    name: '山田花子',
    email: 'hanako.yamada@example.com',
    role: '管理者',
    avatar: '/avatars/hanako.jpg',
    createdAt: '2022-03-15T10:30:00Z',
    lastLogin: '2023-12-01T14:45:00Z',
    postCount: 45,
  };

  const mockHandlers = {
    onEdit: jest.fn(),
    onDelete: jest.fn(),
  };

  beforeEach(() => {
    Object.values(mockHandlers).forEach((mock) =>
      mock.mockClear()
    );
  });

  it('should render collapsed user profile', () => {
    const { container } = render(
      <UserProfile
        user={mockUser}
        onEdit={mockHandlers.onEdit}
        onDelete={mockHandlers.onDelete}
      />
    );

    expect(container.firstChild).toMatchSnapshot();
  });

  it('should render expanded user profile', () => {
    const { container, getByText } = render(
      <UserProfile
        user={mockUser}
        onEdit={mockHandlers.onEdit}
        onDelete={mockHandlers.onDelete}
      />
    );

    // 詳細表示ボタンをクリック
    fireEvent.click(getByText('詳細を表示'));

    expect(container.firstChild).toMatchSnapshot();
  });
});

条件付きレンダリングのテスト

異なる状態や権限に応じたレンダリングのスナップショットテストです。

javascript// components/Dashboard.jsx
import React from 'react';

const Dashboard = ({ user, stats, permissions }) => {
  return (
    <div className='dashboard'>
      <header className='dashboard-header'>
        <h1>ダッシュボード</h1>
        <p>こんにちは、{user.name}さん</p>
      </header>

      <div className='stats-grid'>
        {permissions.canViewSales && (
          <div className='stat-card sales'>
            <h3>売上</h3>
            <p className='stat-value'>
              ¥{stats.sales.toLocaleString()}
            </p>
            <span className='stat-change positive'>
              +{stats.salesGrowth}%
            </span>
          </div>
        )}

        {permissions.canViewUsers && (
          <div className='stat-card users'>
            <h3>ユーザー数</h3>
            <p className='stat-value'>
              {stats.userCount.toLocaleString()}
            </p>
            <span className='stat-change positive'>
              +{stats.userGrowth}%
            </span>
          </div>
        )}

        {permissions.canViewOrders && (
          <div className='stat-card orders'>
            <h3>注文数</h3>
            <p className='stat-value'>
              {stats.orderCount.toLocaleString()}
            </p>
            <span
              className={`stat-change ${
                stats.orderGrowth >= 0
                  ? 'positive'
                  : 'negative'
              }`}
            >
              {stats.orderGrowth >= 0 ? '+' : ''}
              {stats.orderGrowth}%
            </span>
          </div>
        )}
      </div>

      {permissions.isAdmin && (
        <div className='admin-section'>
          <h2>管理者機能</h2>
          <div className='admin-actions'>
            <button className='admin-btn'>
              システム設定
            </button>
            <button className='admin-btn'>
              ユーザー管理
            </button>
            <button className='admin-btn'>
              データベース管理
            </button>
          </div>
        </div>
      )}
    </div>
  );
};

export default Dashboard;
javascript// __tests__/Dashboard.test.jsx
import React from 'react';
import { render } from '@testing-library/react';
import Dashboard from '../components/Dashboard';

describe('Dashboard コンポーネント', () => {
  const baseUser = {
    id: 1,
    name: '管理太郎',
  };

  const baseStats = {
    sales: 1250000,
    salesGrowth: 15.5,
    userCount: 3420,
    userGrowth: 8.2,
    orderCount: 856,
    orderGrowth: -2.1,
  };

  describe('権限による表示切り替え', () => {
    it('should render admin dashboard with all permissions', () => {
      const permissions = {
        canViewSales: true,
        canViewUsers: true,
        canViewOrders: true,
        isAdmin: true,
      };

      const { container } = render(
        <Dashboard
          user={baseUser}
          stats={baseStats}
          permissions={permissions}
        />
      );

      expect(container.firstChild).toMatchSnapshot();
    });

    it('should render limited dashboard for regular user', () => {
      const permissions = {
        canViewSales: false,
        canViewUsers: true,
        canViewOrders: true,
        isAdmin: false,
      };

      const { container } = render(
        <Dashboard
          user={baseUser}
          stats={baseStats}
          permissions={permissions}
        />
      );

      expect(container.firstChild).toMatchSnapshot();
    });

    it('should render minimal dashboard for viewer', () => {
      const permissions = {
        canViewSales: false,
        canViewUsers: false,
        canViewOrders: true,
        isAdmin: false,
      };

      const { container } = render(
        <Dashboard
          user={baseUser}
          stats={baseStats}
          permissions={permissions}
        />
      );

      expect(container.firstChild).toMatchSnapshot();
    });
  });
});

スナップショット更新の管理方法

スナップショット更新のタイミング

スナップショットテストでは、適切なタイミングでスナップショットを更新することが重要です。

意図的な変更の場合

bash# 全てのスナップショットを更新
yarn test --updateSnapshot

# 特定のテストファイルのスナップショットのみ更新
yarn test ProductCard.test.js --updateSnapshot

# インタラクティブモードで選択的に更新
yarn test --watch

更新が必要なケース

#更新タイミング
1意図的な UI 変更デザインシステムの更新
2新機能の追加新しいプロパティやコンポーネントの追加
3バグ修正正しい出力への修正
4リファクタリング構造変更だが見た目は同じ
5依存関係の更新ライブラリ更新による出力形式の変化

インタラクティブな更新方法

Jest のウォッチモードを使用すると、変更を確認しながらスナップショットを更新できます。

bash# ウォッチモードでテスト実行
yarn test --watch

# ウォッチモード中のコマンド
# u: 失敗したスナップショットを更新
# i: インタラクティブにスナップショット更新
# q: ウォッチモードを終了

インタラクティブモードでは、以下のような画面が表示されます:

css2 snapshots failed.
 › 3 snapshots passed.

Watch Usage
 › Press u to update failing snapshots.
 › Press i to update failing snapshots interactively.
 › Press q to quit watch mode.
 › Press Enter to trigger a test run.

差分の確認と検証

スナップショットが更新される前に、必ず差分を確認しましょう。

javascript// 差分表示の例
- Expected  - 3
+ Received  + 3

  <div className="user-card">
    <h3>田中太郎</h3>
-   <p>Email: taro.tanaka@example.com</p>
+   <p>メール: taro.tanaka@example.com</p>
    <p>登録日: 2023/1/15</p>
-   <span className="status active">
+   <span className="status-badge active">
      アクティブ
    </span>
  </div>

差分確認のチェックポイント

javascript// 差分確認時の検証項目
const SnapshotReviewChecklist = {
  content: {
    textChanges: '文言変更は意図的か?',
    structuralChanges: 'HTML構造の変更は必要か?',
    styleChanges: 'CSS クラス名の変更は適切か?',
  },

  impact: {
    accessibility: 'アクセシビリティに影響はないか?',
    seo: 'SEO に悪影響はないか?',
    styling: 'スタイリングが崩れていないか?',
  },

  testing: {
    coverage: '他のテストケースも確認したか?',
    integration: '統合テストへの影響はないか?',
    e2e: 'E2E テストの更新は必要か?',
  },
};

チーム開発での更新ルール

複数人での開発では、スナップショット更新のルールを決めておくことが重要です。

javascript// package.json にスクリプトを追加
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:update-snapshots": "jest --updateSnapshot",
    "test:review-snapshots": "jest --updateSnapshot --verbose",
    "test:ci": "jest --ci --coverage --watchAll=false"
  }
}

推奨されるワークフロー

  1. 個人作業: ローカルでスナップショット更新
  2. コードレビュー: PR でスナップショット変更を確認
  3. マージ前: CI でスナップショットテストが通ることを確認
  4. リリース前: 全スナップショットの整合性を検証
javascript// .github/workflows/test.yml(CI設定例)
name: Test

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v2
      - name: Setup Node.js
        uses: actions/setup-node@v2
        with:
          node-version: '18'
          cache: 'yarn'

      - name: Install dependencies
        run: yarn install --frozen-lockfile

      - name: Run tests
        run: yarn test:ci

      - name: Check snapshot consistency
        run: |
          if git diff --exit-code __snapshots__/; then
            echo "✅ Snapshots are consistent"
          else
            echo "❌ Snapshot files have uncommitted changes"
            exit 1
          fi

スナップショットテストのベストプラクティス

適切なテスト範囲の設定

スナップショットテストは万能ではありません。適切な範囲で使用することが重要です。

スナップショットテストに適した場面

javascript// ✅ 適している:静的なコンポーネント
describe('Footer コンポーネント', () => {
  it('should render footer with links', () => {
    const { container } = render(<Footer />);
    expect(container.firstChild).toMatchSnapshot();
  });
});

// ✅ 適している:設定オブジェクトの出力
describe('Config Generator', () => {
  it('should generate correct webpack config', () => {
    const config = generateWebpackConfig({
      mode: 'production',
      target: 'web',
    });
    expect(config).toMatchSnapshot();
  });
});

// ❌ 適していない:動的な値を含む
describe('Current Time Display', () => {
  it('should show current time', () => {
    const { container } = render(<CurrentTime />);
    // new Date() による値が毎回変わるため不適切
    expect(container.firstChild).toMatchSnapshot();
  });
});

動的な値を含む場合の対処法

javascript// 動的な値をモック化
describe('Dynamic Content', () => {
  beforeEach(() => {
    // 固定の日時を設定
    jest.useFakeTimers();
    jest.setSystemTime(new Date('2023-01-01T00:00:00Z'));
  });

  afterEach(() => {
    jest.useRealTimers();
  });

  it('should render with fixed timestamp', () => {
    const { container } = render(<TimestampedPost />);
    expect(container.firstChild).toMatchSnapshot();
  });
});

// または、動的部分を除外
describe('User Profile with Dynamic ID', () => {
  it('should render user profile', () => {
    const { container } = render(
      <UserProfile userId={123} />
    );

    // data-testid 以外の内容をスナップショット
    const profileContent = container.querySelector(
      '.profile-content'
    );
    expect(profileContent).toMatchSnapshot();
  });
});

読みやすいスナップショットの作成

スナップショットは他の開発者が読むものです。分かりやすい構造を心がけましょう。

javascript// ✅ 良い例:明確なテスト名とシンプルな構造
describe('ProductCard コンポーネント', () => {
  const baseProps = {
    product: {
      id: 1,
      name: 'テスト商品',
      price: 1000,
      inStock: true,
    },
    onAddToCart: jest.fn(),
  };

  describe('基本表示', () => {
    it('通常状態で表示される', () => {
      const { container } = render(
        <ProductCard {...baseProps} />
      );
      expect(container.firstChild).toMatchSnapshot();
    });
  });

  describe('特別状態', () => {
    it('セール価格で表示される', () => {
      const { container } = render(
        <ProductCard
          {...baseProps}
          product={{
            ...baseProps.product,
            salePrice: 800,
          }}
        />
      );
      expect(container.firstChild).toMatchSnapshot();
    });
  });
});

スナップショットサイズの管理

大きすぎるスナップショットは管理が困難になります。

javascript// ❌ 悪い例:巨大なスナップショット
describe('Entire Page', () => {
  it('should render complete dashboard', () => {
    const { container } = render(
      <DashboardPage>
        <Header />
        <Sidebar />
        <MainContent />
        <Footer />
      </DashboardPage>
    );
    // 数千行のスナップショットが生成される
    expect(container).toMatchSnapshot();
  });
});

// ✅ 良い例:コンポーネント単位でのテスト
describe('Dashboard Components', () => {
  it('should render dashboard header', () => {
    const { container } = render(
      <Header user={mockUser} />
    );
    expect(container.firstChild).toMatchSnapshot();
  });

  it('should render sidebar navigation', () => {
    const { container } = render(
      <Sidebar activeItem='dashboard' items={navItems} />
    );
    expect(container.firstChild).toMatchSnapshot();
  });
});

保守性を考慮したテスト設計

長期的な保守性を考慮したスナップショットテストの設計方法です。

javascript// テストデータの一元管理
// __tests__/fixtures/testData.js
export const testProducts = {
  basic: {
    id: 1,
    name: 'テスト商品',
    price: 1000,
    inStock: true,
    imageUrl: '/test-image.jpg',
  },

  outOfStock: {
    id: 2,
    name: '在庫切れ商品',
    price: 2000,
    inStock: false,
    imageUrl: '/test-image2.jpg',
  },

  onSale: {
    id: 3,
    name: 'セール商品',
    price: 1500,
    salePrice: 1200,
    inStock: true,
    imageUrl: '/test-image3.jpg',
  },
};

// テストヘルパーの作成
export const createMockHandlers = () => ({
  onAddToCart: jest.fn(),
  onRemoveFromCart: jest.fn(),
  onToggleFavorite: jest.fn(),
});
javascript// __tests__/ProductCard.test.jsx
import {
  testProducts,
  createMockHandlers,
} from './fixtures/testData';

describe('ProductCard コンポーネント', () => {
  let handlers;

  beforeEach(() => {
    handlers = createMockHandlers();
  });

  Object.entries(testProducts).forEach(([key, product]) => {
    it(`should render ${key} product correctly`, () => {
      const { container } = render(
        <ProductCard product={product} {...handlers} />
      );
      expect(container.firstChild).toMatchSnapshot(
        `${key}-product`
      );
    });
  });
});

スナップショット活用のガイドライン

#項目推奨事項
1テスト名何をテストしているか明確にする
2スナップショット名カスタム名を使用して内容を明確化
3テストデータ再利用可能な形で一元管理
4更新頻度必要以上に頻繁な更新は避ける
5レビュースナップショット変更は必ずレビューする

まとめ

Jest のスナップショットテストは、UI の一貫性維持と効率的なリグレッションテストを実現する強力な機能です。この記事で解説した内容を段階的に実践することで、品質の高いテストコードを作成できるようになるでしょう。

スナップショットテストの価値

スナップショットテストの最大の価値は、少ない労力で広範囲の変更を検出できることです。特に、大規模なフロントエンドアプリケーションでは、手動では発見困難な細かい変更も自動的に検出できます。

適切な活用方法

ただし、スナップショットテストは万能ではありません。静的なコンテンツや設定オブジェクトには適していますが、動的な値を含む処理やユーザーインタラクションのテストには他の手法との組み合わせが必要です。

継続的な改善

スナップショットテストの運用では、適切な更新タイミングの判断と、チーム内でのルール設定が重要になります。CI/CD パイプラインとの連携により、品質を保ちながら開発効率を向上させることができるでしょう。

今後の発展

フロントエンド技術の進化とともに、スナップショットテストの手法も進化していきます。この記事で学んだ基礎知識を土台に、新しいツールやライブラリにも柔軟に対応していってください。

効果的なスナップショットテストの導入により、より安心して開発を進められる環境を構築し、チーム全体の生産性向上に貢献していきましょう。

関連リンク