10日で覚えるJestDay 7: スナップショットテスト
books.chapter 710日で覚えるJest

Day 7: スナップショットテスト

今日学ぶこと

  • スナップショットテストとは何か、いつ使うべきか
  • toMatchSnapshot() の基本的な使い方
  • toMatchInlineSnapshot() でインラインスナップショットを書く
  • スナップショットの更新方法(--updateSnapshot / -u
  • カスタムシリアライザーの作成
  • UI以外のデータ(APIレスポンス、設定オブジェクト)へのスナップショットテスト
  • スナップショットテストのベストプラクティスと落とし穴

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

スナップショットテストは、ある値を「スナップショット」として保存し、以降のテスト実行時にその保存済みの値と比較するテスト手法です。出力が意図せず変わっていないことを自動的に検出できます。

flowchart TB
    subgraph First["初回実行"]
        T1["テスト実行"] --> S1["スナップショット生成"]
        S1 --> F1["__snapshots__/\nファイルに保存"]
    end
    subgraph Later["2回目以降"]
        T2["テスト実行"] --> S2["新しい出力を生成"]
        S2 --> CMP["保存済みスナップショットと比較"]
        CMP -->|"一致"| PASS["テスト成功 ✓"]
        CMP -->|"不一致"| FAIL["テスト失敗 ✗"]
    end
    style First fill:#3b82f6,color:#fff
    style Later fill:#8b5cf6,color:#fff

いつスナップショットテストを使うか

適している場面 適していない場面
UIコンポーネントの出力 ビジネスロジックの検証
APIレスポンスの構造確認 頻繁に変わるデータ
設定オブジェクトの変更検知 動的な値(日付、乱数)
エラーメッセージの一貫性 巨大なデータ構造

toMatchSnapshot() の基本

toMatchSnapshot() は値をスナップショットファイルに保存し、以降の実行で比較します。

// formatUser.js
function formatUser(user) {
  return {
    displayName: `${user.firstName} ${user.lastName}`,
    email: user.email.toLowerCase(),
    role: user.role || 'member',
  };
}

module.exports = { formatUser };
// formatUser.test.js
const { formatUser } = require('./formatUser');

test('formats user correctly', () => {
  const user = {
    firstName: 'Alice',
    lastName: 'Smith',
    email: 'Alice@Example.com',
    role: 'admin',
  };

  expect(formatUser(user)).toMatchSnapshot();
});

TypeScript版:

// formatUser.ts
interface User {
  firstName: string;
  lastName: string;
  email: string;
  role?: string;
}

interface FormattedUser {
  displayName: string;
  email: string;
  role: string;
}

export function formatUser(user: User): FormattedUser {
  return {
    displayName: `${user.firstName} ${user.lastName}`,
    email: user.email.toLowerCase(),
    role: user.role || 'member',
  };
}

初回実行時、__snapshots__/formatUser.test.js.snap ファイルが自動生成されます:

// Jest Snapshot v1, https://goo.gl/fbAXQV

exports[`formats user correctly 1`] = `
{
  "displayName": "Alice Smith",
  "email": "alice@example.com",
  "role": "admin",
}
`;

複数のスナップショット

1つのテストファイル内で複数のスナップショットを使う場合、自動的に連番が付きます。

test('formats different user roles', () => {
  const admin = formatUser({ firstName: 'Alice', lastName: 'Smith', email: 'a@b.com', role: 'admin' });
  const member = formatUser({ firstName: 'Bob', lastName: 'Jones', email: 'b@c.com' });

  // saved as "formats different user roles 1"
  expect(admin).toMatchSnapshot();
  // saved as "formats different user roles 2"
  expect(member).toMatchSnapshot();
});

ヒント: 名前付きスナップショットで可読性を上げることもできます: expect(admin).toMatchSnapshot('admin user')


toMatchInlineSnapshot()

toMatchInlineSnapshot() はスナップショットをテストコード内に直接埋め込みます。外部ファイルを開かずに値を確認できるため、小さなスナップショットに最適です。

test('formats user inline', () => {
  const user = formatUser({
    firstName: 'Alice',
    lastName: 'Smith',
    email: 'Alice@Example.com',
    role: 'admin',
  });

  // first run: Jest automatically fills in the snapshot
  expect(user).toMatchInlineSnapshot(`
    {
      "displayName": "Alice Smith",
      "email": "alice@example.com",
      "role": "admin",
    }
  `);
});

初回実行時に toMatchInlineSnapshot() の引数が空であれば、Jestが自動的にスナップショットを埋め込みます。

flowchart LR
    subgraph External["toMatchSnapshot()"]
        E1["スナップショットを\n外部ファイルに保存"]
        E2["__snapshots__/\n*.snap"]
    end
    subgraph Inline["toMatchInlineSnapshot()"]
        I1["スナップショットを\nテストコード内に埋め込み"]
        I2["テストファイル自体が\n更新される"]
    end
    style External fill:#3b82f6,color:#fff
    style Inline fill:#22c55e,color:#fff
比較項目 toMatchSnapshot() toMatchInlineSnapshot()
保存場所 __snapshots__/*.snap テストファイル内
可読性 別ファイルを開く必要あり テストと一緒に見える
適するサイズ 大きめのスナップショット 小さなスナップショット
コードレビュー 変更が見えにくい 差分が明確

スナップショットの更新

コードの変更により意図的にスナップショットが変わった場合、スナップショットを更新する必要があります。

CLI オプション

# update all snapshots
npx jest --updateSnapshot
# or shorthand
npx jest -u

# update snapshots for a specific test file
npx jest formatUser.test.js -u

インタラクティブモード(watch mode)

jest --watch でテスト実行中、失敗したスナップショットに対して u キーを押すと更新できます。

Snapshot Summary
 › 1 snapshot failed from 1 test suite.
   › Press `u` to update failing snapshots.

注意: 更新前に必ず差分を確認してください。意図しない変更を見逃すと、スナップショットテストの意味がなくなります。


動的な値の処理

日付やIDなど、実行ごとに変わる値にはプロパティマッチャーを使います。

test('creates a user with dynamic fields', () => {
  const user = {
    id: Math.random().toString(36).substr(2, 9),
    name: 'Alice',
    createdAt: new Date().toISOString(),
  };

  expect(user).toMatchSnapshot({
    id: expect.any(String),
    createdAt: expect.any(String),
  });
});

この場合、idcreatedAtexpect.any(String) で検証され、name はスナップショットの値と完全一致で比較されます。

TypeScript版:

interface UserRecord {
  id: string;
  name: string;
  createdAt: string;
}

test('creates a user with dynamic fields', () => {
  const user: UserRecord = {
    id: Math.random().toString(36).substr(2, 9),
    name: 'Alice',
    createdAt: new Date().toISOString(),
  };

  expect(user).toMatchSnapshot({
    id: expect.any(String),
    createdAt: expect.any(String),
  });
});

インラインスナップショットでも同様に使えます:

test('user with dynamic fields inline', () => {
  const user = {
    id: 'abc123',
    name: 'Alice',
    createdAt: '2025-01-01T00:00:00.000Z',
  };

  expect(user).toMatchInlineSnapshot(
    {
      id: expect.any(String),
      createdAt: expect.any(String),
    },
    `
    {
      "createdAt": Any<String>,
      "id": Any<String>,
      "name": "Alice",
    }
  `
  );
});

カスタムシリアライザー

Jestのデフォルトシリアライザーでは不十分な場合、カスタムシリアライザーを作成できます。

// stripAnsi.serializer.js
// Custom serializer that removes ANSI escape codes
module.exports = {
  serialize(val) {
    return val.replace(/\u001b\[[0-9;]*m/g, '');
  },
  test(val) {
    return typeof val === 'string' && /\u001b\[[0-9;]*m/.test(val);
  },
};

Jest設定に登録

// jest.config.js
module.exports = {
  snapshotSerializers: ['./stripAnsi.serializer.js'],
};

テスト内で個別に追加

expect.addSnapshotSerializer({
  serialize(val) {
    // format Date objects as YYYY-MM-DD
    return `"${val.toISOString().split('T')[0]}"`;
  },
  test(val) {
    return val instanceof Date;
  },
});

test('date snapshot', () => {
  const date = new Date('2025-06-15T12:00:00Z');
  expect(date).toMatchInlineSnapshot(`"2025-06-15"`);
});

TypeScript版:

import type { NewPlugin } from 'pretty-format';

const dateSerializer: NewPlugin = {
  serialize(val: Date) {
    return `"${val.toISOString().split('T')[0]}"`;
  },
  test(val: unknown): val is Date {
    return val instanceof Date;
  },
};

expect.addSnapshotSerializer(dateSerializer);

UI以外のスナップショットテスト

スナップショットテストはUIコンポーネントだけでなく、さまざまなデータ構造に適用できます。

APIレスポンスの構造検証

// apiClient.test.js
const { fetchUserProfile } = require('./apiClient');

test('user profile API response structure', async () => {
  const profile = await fetchUserProfile(1);

  expect(profile).toMatchSnapshot({
    id: expect.any(Number),
    lastLogin: expect.any(String),
  });
});

設定オブジェクトの検証

// config.test.js
const { getConfig } = require('./config');

test('production config snapshot', () => {
  const config = getConfig('production');

  expect(config).toMatchInlineSnapshot(`
    {
      "database": {
        "host": "db.production.example.com",
        "port": 5432,
        "ssl": true,
      },
      "logging": {
        "level": "error",
      },
    }
  `);
});

エラーメッセージの検証

// validator.test.js
const { validate } = require('./validator');

test('validation error messages', () => {
  const errors = validate({
    name: '',
    email: 'invalid',
    age: -1,
  });

  expect(errors).toMatchInlineSnapshot(`
    [
      "Name is required",
      "Email format is invalid",
      "Age must be a positive number",
    ]
  `);
});

ベストプラクティス

1. 小さく焦点を絞ったスナップショット

// BAD: snapshot of the entire component tree
test('renders page', () => {
  const tree = render(<EntirePage />);
  expect(tree).toMatchSnapshot(); // hundreds of lines
});

// GOOD: snapshot of specific parts
test('renders user card', () => {
  const card = render(<UserCard name="Alice" role="admin" />);
  expect(card).toMatchSnapshot();
});

2. 説明的なテスト名を付ける

// BAD
test('snapshot 1', () => { ... });

// GOOD
test('renders error state when API fails', () => { ... });

3. スナップショットファイルをコミットする

__snapshots__/ ディレクトリはバージョン管理に含めましょう。コードレビューでスナップショットの変更も確認できます。

4. 不要なスナップショットを削除する

テストを削除したら、対応するスナップショットも残ります。定期的にクリーンアップしましょう:

npx jest --ci --updateSnapshot
flowchart TB
    subgraph Good["良いスナップショット"]
        G1["小さく焦点が明確"]
        G2["変更の理由が分かる"]
        G3["レビューしやすい"]
    end
    subgraph Bad["悪いスナップショット"]
        B1["巨大で全体を含む"]
        B2["変更の意図が不明"]
        B3["盲目的に更新される"]
    end
    style Good fill:#22c55e,color:#fff
    style Bad fill:#ef4444,color:#fff

よくある落とし穴

スナップショット疲れ(Snapshot Fatigue)

大量のスナップショットテストがあると、差分を確認せずに jest -u で一括更新しがちです。これではバグを見逃す危険性があります。

flowchart LR
    A["スナップショットが多すぎる"] --> B["変更のたびに\n大量の失敗"]
    B --> C["差分を確認せず\n一括更新"]
    C --> D["バグを見逃す"]
    D --> E["スナップショットテストが\n無意味になる"]
    style A fill:#f59e0b,color:#fff
    style E fill:#ef4444,color:#fff

対策:

  • スナップショットは本当に必要な箇所だけに使う
  • 巨大なスナップショットを小さく分割する
  • コードレビューでスナップショットの差分を必ず確認する

意味のない更新

プラットフォーム依存の値や、スタイルの微細な変更でスナップショットが壊れることがあります。

// BAD: platform-dependent snapshot
test('snapshot with platform-dependent value', () => {
  expect(process.platform).toMatchSnapshot();
});

// BAD: snapshot with constantly changing style
test('snapshot with CSS-in-JS', () => {
  // auto-generated class names change on every build
  expect(tree).toMatchSnapshot();
});

大きすぎるスナップショット

数百行のスナップショットは誰もレビューしません。

アンチパターン 改善策
ページ全体のスナップショット コンポーネント単位に分割
動的な値を含むスナップショット プロパティマッチャーを使用
差分を確認せず更新 コードレビューで差分を確認
全てにスナップショットテスト 適切なテスト手法を選択

まとめ

概念 説明
toMatchSnapshot() 値を外部ファイルに保存して比較
toMatchInlineSnapshot() 値をテストコード内に埋め込んで比較
--updateSnapshot / -u スナップショットを更新
プロパティマッチャー 動的な値を expect.any() で柔軟に検証
カスタムシリアライザー スナップショットのフォーマットをカスタマイズ
スナップショット疲れ 大量のスナップショットで差分確認を怠る問題

重要ポイント

  1. スナップショットテストは「何が変わったか」を検出する手法であり、「何が正しいか」を検証する手法ではない
  2. 小さく焦点を絞ったスナップショットを心がける
  3. 動的な値にはプロパティマッチャーを使う
  4. スナップショットの差分は必ずコードレビューで確認する
  5. スナップショットテストだけに頼らず、適切なテスト手法と組み合わせる

練習問題

問題1: 基本

以下の formatProduct 関数に対して、toMatchSnapshot() を使ったテストを書いてください。

function formatProduct(product) {
  return {
    title: product.name.toUpperCase(),
    price: `$${product.price.toFixed(2)}`,
    inStock: product.quantity > 0,
  };
}

問題2: 応用

以下の generateReport 関数に対して、動的なフィールド(generatedAt, id)をプロパティマッチャーで処理しつつ、toMatchInlineSnapshot() を使ったテストを書いてください。

function generateReport(data) {
  return {
    id: Math.random().toString(36).substr(2, 9),
    title: `Report: ${data.name}`,
    itemCount: data.items.length,
    generatedAt: new Date().toISOString(),
  };
}

チャレンジ問題

Date オブジェクトを YYYY/MM/DD 形式でシリアライズするカスタムシリアライザーを作成し、それを使ったスナップショットテストを書いてください。


参考リンク


次回予告: Day 8では「カバレッジとデバッグ」について学びます。テストカバレッジの計測方法、カバレッジレポートの読み方、そしてテストのデバッグテクニックを詳しく見ていきましょう!