// TESTING長文・約 146,572

テストピラミッド vs テスティングトロフィー: モダンフロントエンドのテスト戦略ガイド

テストピラミッド vs テスティングトロフィー: モダンフロントエンドのテスト戦略ガイド

「どのテストをどれくらい書くべき?」その悩みを解決。テストピラミッドとテスティングトロフィーの違いを理解し、React/TypeScript/Next.jsプロジェクトに最適なテスト戦略を実例付きで解説します。

// この記事のポイント

  • モダンフロントエンド(TypeScript / React)には テスティングトロフィー型が適している
  • 推奨配分:静的テスト(基盤)+ 結合 60% > 単体 20% > E2E 10%
  • TypeScript の型チェックで多くのバグを事前に潰し、テストコスト自体を削減
  • 結合テストは「ユーザー視点の振る舞い」を検証し リファクタリング耐性が高い。E2E は クリティカルフローのみに絞って CI 時間を最適化

はじめに

「テストを書いているのに、リファクタリングするたびに大量のテストが壊れる...」

「E2Eテストを増やしたらCIが30分以上かかるようになった」

「単体テストと結合テスト、どっちをどれくらい書けばいいの?」

こんな悩みを抱えていませんか?

テスト戦略には「テストピラミッド」と「テスティングトロフィー」という2つの考え方があります。これらを理解することで、テストへの投資効率を最大化し、開発の生産性を高められます。

本記事では、それぞれの考え方の違いと、モダンフロントエンド(TypeScript/React/Next.js)での実践方法を、実際のコード例とともに解説します。

テストピラミッドとは

テストピラミッド(Test Pyramid) は、Mike Cohn氏が提唱し、Martin Fowler氏のブログで広く知られるようになった自動テストの古典的モデルです。

テストピラミッド

基本的な考え方

  • 下層(単体テスト)を厚く: 高速で安価、バグの局所化が容易
  • 上層(E2E)は薄く: 実行が遅く、脆い(brittle)、メンテナンスコストが高い

Martin Fowler氏はUI経由のE2Eテストについて「脆さ、開発コストの高さ、実行の遅さ」を指摘し、単体テスト中心のアプローチを推奨しました(出典: TestPyramid - Martin Fowler)。

ピラミッドが生まれた背景

テストピラミッドは、Mike Cohn氏が2009年の著書『Succeeding with Agile』で提唱したモデルです。Cohn氏は2003〜2004年頃にこの考え方を構想し、2004年のスクラムギャザリングで発表していました。その後、Martin Fowler氏2012年のブログ記事で解説したことで、広く知られるようになりました。

当時のUIテスト自動化の問題点

2000年代後半、UI経由のE2Eテストには深刻な課題がありました:

  • 実行が遅い: ブラウザの起動、ページ読み込み、DOM操作に時間がかかり、テストスイート全体の実行に数時間かかることも珍しくなかった
  • 脆い(brittle): UIの小さな変更(ボタンの位置、テキストの変更)でテストが壊れる
  • 記録・再生ツールの限界: 当時主流だったSelenium IDEなどの記録・再生ツールは、変更に弱く、保守性の低いテストコードを生成しがちだった
  • 非決定性(Flaky): ネットワーク遅延やタイミングの問題で、同じテストが成功したり失敗したりする

Martin Fowler氏は当時のUI自動化について「記録・再生ツールは変更への耐性を阻害し、有用な抽象化を妨げる」と指摘しています。

だから「低レベルテスト重視」だった

このような背景から、「できるだけUIを経由せず、低レベル(単体テスト)でテストする」ことが合理的な戦略でした。単体テストは:

  • ミリ秒単位で実行できる
  • 失敗しても原因箇所が明確
  • UIの変更に影響されない

という特性があり、当時の技術的制約の中では最も効率的な選択だったのです。

テスティングトロフィーとは

テスティングトロフィー(Testing Trophy) は、Kent C. Dodds氏が提唱した新しいテスト戦略モデルです。

テスティングトロフィー

基本的な考え方

  • 静的テスト: TypeScript・ESLintで多くのバグを事前に防ぐ
  • 結合テストを最重視: 信頼性とコストのバランスが最も良い
  • 単体テストは適度に: 複雑なロジックのみ
  • E2Eは重要なフローのみ: 登録・ログインなどクリティカルなパス

Kent C. Dodds氏は「現代のツールは素晴らしく進歩しており、従来のピラミッド前提は当てはまらなくなった」と述べています(出典: Write tests. Not too many. Mostly integration.)。

なぜ「トロフィー」型なのか

「トロフィー」という名前は、テストの種類ごとの推奨比率を視覚化したとき、トロフィー(優勝カップ)に似た形状になることから付けられました。ピラミッドは下(単体テスト)が最も広いのに対し、トロフィーは中央(結合テスト)が最も膨らみ、土台に静的テストがあるのが特徴です。

この形になる理由

Kent C. Dodds氏の有名なフレーズ「Write tests. Not too many. Mostly integration.」(テストを書こう。書きすぎず。主に結合テストを。)がこの形状を端的に表しています。

  1. Write tests(テストを書こう): テストは品質保証に不可欠
  2. Not too many(書きすぎず): 過剰なテストはメンテナンスコストを増大させる
  3. Mostly integration(主に結合テストを): 信頼性とコストのバランスが最も良い層に投資する

結合テストは「ユーザーに近い視点」でテストしつつ、E2Eほど遅くならないスイートスポットです。だから中央が膨らんだトロフィー型になります。

ピラミッドからトロフィーへの変化の背景

観点 ピラミッド時代(〜2015年頃) トロフィー時代(現在)
E2Eツール Selenium等(不安定・遅い) Playwright/Cypress(安定・高速化)
型システム 動的型付けが主流 TypeScriptの普及
UIテスト 困難・脆い Testing Libraryで容易に
モック 複雑・不安定 MSW等で簡単に

ツールの進化により、結合テストのコストが下がり、静的テストで多くのバグを防げるようになりました。その結果、単体テストに過度に依存する必要がなくなったのです。

各レイヤーの実践

1. 静的テスト(Static Testing)

静的テストは、コードを実行する前にバグを検出する最初の防衛線です。

// tsconfig.json - 厳格な型チェック設定
{
  "compilerOptions": {
    "strict": true,                          // 厳格な型チェックを有効化
    "forceConsistentCasingInFileNames": true,
    "noUncheckedIndexedAccess": true,        // インデックスアクセスの安全性
    "noImplicitReturns": true                // 暗黙のreturnを禁止
  }
}
// .eslintrc - コード品質ルール
{
  "extends": ["eslint:recommended", "@typescript-eslint/recommended"],
  "rules": {
    "@typescript-eslint/no-unused-vars": "error",
    "@typescript-eslint/no-explicit-any": "warn"
  }
}

メリット:

  • 実行コストがほぼゼロ
  • バグの早期発見
  • IDEとの連携でリアルタイムフィードバック

2. 単体テスト(Unit Tests)

純粋なロジックや型ガードなど、外部依存のないコードをテストします。

// 型ガード関数のテスト例
import { describe, it, expect } from 'vitest';
import { assertStatus, ValidationError } from './validators';
 
describe('assertStatus', () => {
  it('有効なステータスの場合、エラーをスローしない', () => {
    expect(() => assertStatus('pending')).not.toThrow();
    expect(() => assertStatus('completed')).not.toThrow();
    expect(() => assertStatus(null)).not.toThrow();
  });
 
  it('無効なステータスの場合、エラーをスローする', () => {
    expect(() => assertStatus('invalid')).toThrow(ValidationError);
  });
});

ポイント:

  • 複雑なビジネスロジックに焦点
  • モックは必要最小限に
  • 高速実行・高い再現性

なお、「モック」という言葉は広く使われていますが、実際にはStub、Spy、Fakeなど目的によって使い分けがあります。これらの違いを理解すると、より適切なテストが書けるようになります。

テストダブル完全ガイド:Vitest + React Testing Libraryでの実践

テストダブル完全ガイド:Vitest + React Testing Libraryでの実践

Martin Fowlerのテストダブル分類を基に、Vitest + React Testing Libraryでの実装パターンを解説。vi.fn()、vi.spyOn()、vi.mock()の使い分けとベストプラクティスを紹介します。

3. 結合テスト(Integration Tests)

テスティングトロフィーで最も重要なレイヤーです。複数のユニットが連携して正しく動作するかを検証します。

// Reactコンポーネントの結合テスト
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
 
describe('LoginForm', () => {
  it('フォーム入力と送信が正しく動作する', async () => {
    const user = userEvent.setup();
    const onSubmit = vi.fn();
 
    render(<LoginForm onSubmit={onSubmit} />);
 
    // ユーザー視点で操作
    await user.type(
      screen.getByRole('textbox', { name: 'メールアドレス' }),
      'test@example.com'
    );
    await user.type(
      screen.getByLabelText('パスワード'),
      'password123'
    );
    await user.click(screen.getByRole('button', { name: 'ログイン' }));
 
    expect(onSubmit).toHaveBeenCalledWith({
      email: 'test@example.com',
      password: 'password123',
    });
  });
});

結合テストのベストプラクティス:

  1. モックは最小限に: Kent C. Dodds氏は「HTTPリクエストだけモックし、他は実物を使う」ことを推奨(モックが多いと実装詳細に依存し、リファクタリング時にテストが壊れやすくなるため)
  2. ユーザー視点でテスト: 内部実装ではなく、ユーザーに見える振る舞いを検証
  3. getByRoleを優先: アクセシビリティと堅牢性を両立

vi.hoistedを使ったモックパターンやgetByRoleの使い分けなど、より詳細な実装テクニックは以下の記事で解説しています。

Reactテストの困りごとを解決: Vitest & Testing Libraryベストプラクティス

Reactテストの困りごとを解決: Vitest & Testing Libraryベストプラクティス

「ESMモジュールのモッキングがうまくいかない」「どのクエリを使うべきか迷う」そんなReactテストの困りごとを、vi.hoistedパターンとTesting Libraryのベストプラクティスで解決します。

// APIレイヤーの結合テスト(tRPC例)
import { describe, it, expect } from 'vitest';
import { createTestCaller } from './test-utils';
 
describe('userRouter', () => {
  it('認証済みユーザーはプロフィールを取得できる', async () => {
    const caller = createTestCaller({ userId: 'user-1', role: 'member' });
 
    const profile = await caller.user.getProfile();
 
    expect(profile).toMatchObject({
      id: 'user-1',
      name: expect.any(String),
    });
  });
 
  it('未認証ユーザーはアクセスできない', async () => {
    const caller = createTestCaller({ session: null });
 
    await expect(caller.user.getProfile()).rejects.toThrow('UNAUTHORIZED');
  });
});

4. E2Eテスト(End-to-End Tests)

システム全体を通したユーザーフローを検証します。重要なシナリオに絞るのがポイントです。

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
 
export default defineConfig({
  testDir: './e2e',
  retries: process.env.CI ? 2 : 0,
  timeout: 30000,
 
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',        // 失敗時のみトレース
    screenshot: 'only-on-failure',  // 失敗時のみスクリーンショット
  },
 
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
});
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
 
test('ユーザー登録からログインまでの一連のフロー', async ({ page }) => {
  // 登録
  await page.goto('/signup');
  await page.getByLabel('メールアドレス').fill('new@example.com');
  await page.getByLabel('パスワード').fill('securePassword123');
  await page.getByRole('button', { name: '登録' }).click();
 
  await expect(page).toHaveURL('/dashboard');
  await expect(page.getByRole('heading', { name: 'ダッシュボード' })).toBeVisible();
});

E2Eテストの原則:

  • クリティカルなユーザーフローのみ
  • 1つのシナリオで複数の機能を検証(例: 登録フローでフォームバリデーション、API通信、画面遷移を一度にテスト)
  • 失敗時の原因特定を容易にする工夫(スクリーンショット、トレース)

ピラミッド vs トロフィー: どちらを選ぶべきか?

テスト種類の比較

特性 単体テスト 結合テスト E2Eテスト
実行速度 非常に速い 速い 遅い
信頼性 バグの局所化 ユニット間の連携 実ユーザー体験
コスト 低い 中程度 高い
脆さ 実装詳細に依存しがち バランスが良い UIの変更に弱い
カバー範囲 狭い 中程度 広い

フロントエンド開発ではトロフィーを推奨

モダンフロントエンド(TypeScript + React + Next.js)では、テスティングトロフィーが効果的です。

理由:

  1. TypeScriptの進化: 型チェックで多くのバグを事前に防げる
  2. Testing Libraryの成熟: ユーザー視点のテストが書きやすくなった
  3. Playwrightの安定性: E2Eテストが以前より信頼できるようになった
  4. コンポーネント指向: UIコンポーネントは結合テストとの相性が良い

推奨テスト配分(モダンフロントエンド)

レイヤー 配分目安 対象
静的テスト 100%(基盤) すべてのコードにTypeScript + ESLintを適用
結合テスト 60% UIコンポーネント、APIレイヤー、状態管理
単体テスト 20% 複雑なビジネスロジック、純粋関数、型ガード
E2Eテスト 10% ユーザー登録、ログイン、決済などクリティカルフロー

※プロジェクトの特性により調整してください。純粋なロジックが多い場合は単体テストを増やし、UIが複雑な場合は結合テストを重視します。

バックエンドやライブラリ開発ではピラミッドも有効

一方、バックエンドやライブラリ開発では、ピラミッド型も依然として有効です。

理由1: UIがない = 実装詳細への依存が問題になりにくい

フロントエンドで単体テストが「壊れやすい」と言われるのは、UIの変更(ボタンの位置、コンポーネント構造、スタイリング)が頻繁に起きるためです。一方、バックエンドには「見た目」がないため、公開API(エンドポイントや関数シグネチャ)が安定している限り、内部実装を変えてもテストが壊れにくいという特性があります。

理由2: 純粋関数・ビジネスロジックが多い

バックエンドには以下のような「入力→処理→出力」が明確なロジックが多く存在します:

  • データ変換・整形(DTO変換、レスポンス整形)
  • バリデーション(入力値検証、ビジネスルールチェック)
  • 計算ロジック(料金計算、集計処理)
  • 状態遷移(ステータス更新の可否判定)

これらは外部依存なしにテストでき、単体テストで高速かつ網羅的に検証できます

理由3: 結合テストのコストが相対的に高い

バックエンドの結合テストには、データベース接続、外部APIのモック、メッセージキューのセットアップなどが必要です。フロントエンドの結合テスト(jsdomでのレンダリング)と比べて、セットアップ/クリーンアップのコストが高く、実行も遅くなりがちです。

理由4: エッジケースの網羅性

複雑なビジネスロジックには多くのエッジケース(境界値、例外パターン、組み合わせ)があります。これらを結合テストで全パターンカバーしようとすると、テスト数が爆発的に増えます。単体テストなら高速に多くのパターンを検証できます

// バックエンドでの単体テストが有効な例:料金計算ロジック
describe('calculatePrice', () => {
  it('基本料金を返す', () => {
    expect(calculatePrice({ quantity: 1, plan: 'basic' })).toBe(1000);
  });
 
  it('数量割引が適用される', () => {
    expect(calculatePrice({ quantity: 10, plan: 'basic' })).toBe(9000); // 10%割引
  });
 
  it('プレミアムプランは20%増', () => {
    expect(calculatePrice({ quantity: 1, plan: 'premium' })).toBe(1200);
  });
 
  it('数量が0以下はエラー', () => {
    expect(() => calculatePrice({ quantity: 0, plan: 'basic' })).toThrow();
  });
 
  // ... 多くのエッジケースを高速にテスト
});

まとめ: コンテキストで使い分ける

領域 推奨モデル 理由
フロントエンド(React等) トロフィー型 UIは変更が多く、結合テストがリファクタリング耐性◎
バックエンドAPI ピラミッド寄り 純粋ロジックが多く、単体テストのROIが高い
ライブラリ・SDK ピラミッド型 公開APIが安定、単体テストで契約を保証
フルスタック ハイブリッド 層ごとに適切なモデルを適用

重要なのは「どちらが正しいか」ではなく、プロジェクトの特性に合わせて最適なバランスを見つけることです。

なお、「単体テスト」自体の定義にも、モックを多用する「ロンドン派」と本物のオブジェクトを使う「デトロイト派」という2つの流派があります。テスト戦略の選択と密接に関わるテーマです。

ロンドン派 vs デトロイト派:単体テスト2大流派の思想と使い分け

ロンドン派 vs デトロイト派:単体テスト2大流派の思想と使い分け

「モックはどこまで使うべき?」その議論の裏には、単体テストの考え方が根本的に異なる2つの流派がある。Martin Fowlerの「Mocks Aren't Stubs」とKhorikovの『単体テストの考え方/使い方』を基に、ロンドン派とデトロイト派の歴史・思想・使い分けを事実ベースで整理します。

テスト戦略を理解するメリット

1. テスト投資の最適化

「全部E2Eでテストすれば安心」という考えは非効率です。各テストにはメリット・デメリットがあり、トレードオフを理解して選択することが重要です。

各テストレイヤーのトレードオフ

テスト メリット デメリット
静的テスト コストほぼゼロ、即時フィードバック 実行時のバグは検出できない
単体テスト 高速、バグの局所化が容易、網羅しやすい カバー範囲が狭い、実装詳細に依存しがち
結合テスト 信頼性とコストのバランス◎、リファクタリング耐性 セットアップがやや複雑
E2Eテスト 実ユーザー体験に最も近い、高い信頼性 遅い、脆い、メンテナンスコスト高

トレードオフの軸

  • 速度 vs 信頼性: 単体テストは速いが検出範囲が狭い。E2Eは信頼性が高いが遅い
  • コスト vs カバー範囲: 低コストなテストほどカバー範囲が狭くなる傾向
  • 安定性 vs 実環境への近さ: モックを増やすと安定するが、実環境との乖離が生まれる
  • フィードバック速度 vs 網羅性: 高速なフィードバックを得るか、広範囲を検証するか

これらのトレードオフを理解し、適切な場所に適切なテストを書くことで、同じ工数でより高い品質保証を実現できます。「このロジックは単体テストで十分か?」「ここは結合テストでユーザー視点の検証が必要か?」と考える習慣が、テスト投資の効率を高めます。

2. リファクタリング耐性の向上

単体テストが実装詳細に依存しすぎると、リファクタリングのたびにテストが壊れます。これは「テストが足かせになる」状態であり、開発速度を低下させます。

実装詳細に依存したテストの例(壊れやすい)

// ❌ 内部実装に依存したテスト
it('handleSubmitが呼ばれるとsetLoadingがtrueになる', () => {
  const { result } = renderHook(() => useLoginForm());
 
  act(() => {
    result.current.handleSubmit();
  });
 
  // 内部状態をテストしている → 実装を変えると壊れる
  expect(result.current.isLoading).toBe(true);
});

振る舞いをテストする例(壊れにくい)

// ✅ ユーザーから見える振る舞いをテスト
it('送信中はボタンが無効化され、ローディング表示になる', async () => {
  const user = userEvent.setup();
  render(<LoginForm />);
 
  await user.click(screen.getByRole('button', { name: 'ログイン' }));
 
  // ユーザーに見える結果をテスト → 内部実装が変わっても壊れない
  expect(screen.getByRole('button', { name: 'ログイン' })).toBeDisabled();
  expect(screen.getByText('送信中...')).toBeInTheDocument();
});

結合テストは「ユーザーにとっての振る舞い」を検証するため、内部のstate管理やHooksの実装を変更してもテストが壊れません。テストが壊れるのは、ユーザー体験が変わったときだけになります。

3. CI/CDパイプラインの効率化

テスト種類ごとに実行タイミングを分けることで、フィードバックループを最適化できます。すべてのテストを毎回実行すると、開発者は結果を待つ時間が長くなり、生産性が低下します。

段階的テスト実行の考え方

タイミング 実行するテスト 目的 目安時間
コミット時(ローカル) 静的テスト + 変更ファイル関連の単体テスト 即時フィードバック 〜30秒
プルリクエスト時 静的 + 単体 + 結合テスト 品質ゲート 3〜5分
マージ時 E2Eテスト(クリティカルパス) 統合確認 10〜15分
リリース前 フルE2Eテスト + 回帰テスト 最終確認 30分〜

GitHub Actionsでの実装例

# .github/workflows/ci.yml
name: CI
 
on:
  pull_request:
    branches: [main, develop]
 
jobs:
  # 高速なテストを先に実行(失敗したら後続をスキップ)
  static-and-unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install dependencies
        run: npm ci
      - name: Type check
        run: npm run check-types
      - name: Lint
        run: npm run lint
      - name: Unit tests
        run: npm run test:unit
 
  # 単体テスト通過後に結合テスト
  integration:
    needs: static-and-unit
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Integration tests
        run: npm run test:integration
 
  # マージ時のみE2E実行
  e2e:
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    needs: integration
    runs-on: ubuntu-latest
    steps:
      - name: E2E tests
        run: npm run test:e2e

この構成により、PRでは5分以内にフィードバックを得られ、開発のリズムを保てます。

4. チーム内コミュニケーションの改善

テスト戦略の共通言語を持つことで、チーム内の議論がスムーズになります。

コードレビューでの会話例

❌ 曖昧な議論:
「このテスト、もっとちゃんと書いた方がいいのでは?」
「ちゃんとって何ですか...?」
 
✅ 共通言語がある議論:
「この機能は複数コンポーネントが連携するので、
  単体テストより結合テストの方が適切では?」
「確かに、ユーザー視点で振る舞いをテストした方が
  リファクタリング耐性も高くなりますね」

テスト戦略をドキュメント化するメリット

  • 新メンバーのオンボーディングが早くなる
  • 「どこまでテストを書くか」の判断基準が明確になる
  • レビューの観点が統一され、議論の質が上がる

チームで以下を明文化しておくと効果的です:

## テスト方針(例)
- 静的テスト: 全コードに適用(TypeScript strict + ESLint)
- 単体テスト: 複雑なビジネスロジック、ユーティリティ関数
- 結合テスト: UIコンポーネント、APIハンドラ(メイン)
- E2E: ユーザー登録、ログイン、決済フローのみ

まとめ

  • テストピラミッドは単体テスト重視の古典的モデル
  • テスティングトロフィーは結合テスト重視のモダンモデル
  • TypeScript + React環境ではトロフィー型が効率的
  • 各レイヤーの役割を理解し、適材適所でテストを書く
  • テスト戦略の理解は、開発生産性と品質の両方を向上させる

テスト戦略を見直すことで、「テストを書いているのに不安が消えない」という状況から脱却できます。ぜひ自分のプロジェクトに合った戦略を選んでみてください。

// 参考文献

// SHARE
鶴田 篤広
// ABOUT THE AUTHOR
鶴田 篤広
ソフトウェアエンジニア · クレインテック株式会社
// CRANE TECH — TECHNICAL ADVISORY

テスト戦略の導入・改善でお困りですか?

テストの自動化から品質基盤の構築まで、御社のプロダクトに最適なテスト戦略をご提案します。