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

「どのテストをどれくらい書くべき?」その悩みを解決。テストピラミッドとテスティングトロフィーの違いを理解し、React/TypeScript/Next.jsプロジェクトに最適なテスト戦略を実例付きで解説します。
「テストを書いているのに、リファクタリングするたびに大量のテストが壊れる...」
「E2Eテストを増やしたらCIが30分以上かかるようになった」
「単体テストと結合テスト、どっちをどれくらい書けばいいの?」
こんな悩みを抱えていませんか?
テスト戦略には「テストピラミッド」と「テスティングトロフィー」という2つの考え方があります。これらを理解することで、テストへの投資効率を最大化し、開発の生産性を高められます。
本記事では、それぞれの考え方の違いと、モダンフロントエンド(TypeScript/React/Next.js)での実践方法を、実際のコード例とともに解説します。
テストピラミッド(Test Pyramid) は、Mike Cohn氏が提唱し、Martin Fowler氏のブログで広く知られるようになった自動テストの古典的モデルです。

Martin Fowler氏はUI経由のE2Eテストについて「脆さ、開発コストの高さ、実行の遅さ」を指摘し、単体テスト中心のアプローチを推奨しました(出典: TestPyramid - Martin Fowler)。
テストピラミッドは、Mike Cohn氏が2009年の著書『Succeeding with Agile』で提唱したモデルです。Cohn氏は2003〜2004年頃にこの考え方を構想し、2004年のスクラムギャザリングで発表していました。その後、Martin Fowler氏が2012年のブログ記事で解説したことで、広く知られるようになりました。
当時のUIテスト自動化の問題点
2000年代後半、UI経由のE2Eテストには深刻な課題がありました:
Martin Fowler氏は当時のUI自動化について「記録・再生ツールは変更への耐性を阻害し、有用な抽象化を妨げる」と指摘しています。
だから「低レベルテスト重視」だった
このような背景から、「できるだけUIを経由せず、低レベル(単体テスト)でテストする」ことが合理的な戦略でした。単体テストは:
という特性があり、当時の技術的制約の中では最も効率的な選択だったのです。
テスティングトロフィー(Testing Trophy) は、Kent C. Dodds氏が提唱した新しいテスト戦略モデルです。

Kent C. Dodds氏は「現代のツールは素晴らしく進歩しており、従来のピラミッド前提は当てはまらなくなった」と述べています(出典: Write tests. Not too many. Mostly integration.)。
「トロフィー」という名前は、テストの種類ごとの推奨比率を視覚化したとき、トロフィー(優勝カップ)に似た形状になることから付けられました。ピラミッドは下(単体テスト)が最も広いのに対し、トロフィーは中央(結合テスト)が最も膨らみ、土台に静的テストがあるのが特徴です。
この形になる理由
Kent C. Dodds氏の有名なフレーズ「Write tests. Not too many. Mostly integration.」(テストを書こう。書きすぎず。主に結合テストを。)がこの形状を端的に表しています。
結合テストは「ユーザーに近い視点」でテストしつつ、E2Eほど遅くならないスイートスポットです。だから中央が膨らんだトロフィー型になります。
ピラミッドからトロフィーへの変化の背景
| 観点 | ピラミッド時代(〜2015年頃) | トロフィー時代(現在) |
|---|---|---|
| E2Eツール | Selenium等(不安定・遅い) | Playwright/Cypress(安定・高速化) |
| 型システム | 動的型付けが主流 | TypeScriptの普及 |
| UIテスト | 困難・脆い | Testing Libraryで容易に |
| モック | 複雑・不安定 | MSW等で簡単に |
ツールの進化により、結合テストのコストが下がり、静的テストで多くのバグを防げるようになりました。その結果、単体テストに過度に依存する必要がなくなったのです。
静的テストは、コードを実行する前にバグを検出する最初の防衛線です。
// 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"
}
}メリット:
純粋なロジックや型ガードなど、外部依存のないコードをテストします。
// 型ガード関数のテスト例
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など目的によって使い分けがあります。これらの違いを理解すると、より適切なテストが書けるようになります。
テスティングトロフィーで最も重要なレイヤーです。複数のユニットが連携して正しく動作するかを検証します。
// 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',
});
});
});結合テストのベストプラクティス:
vi.hoistedを使ったモックパターンやgetByRoleの使い分けなど、より詳細な実装テクニックは以下の記事で解説しています。
// 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');
});
});システム全体を通したユーザーフローを検証します。重要なシナリオに絞るのがポイントです。
// 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テストの原則:
| 特性 | 単体テスト | 結合テスト | E2Eテスト |
|---|---|---|---|
| 実行速度 | 非常に速い | 速い | 遅い |
| 信頼性 | バグの局所化 | ユニット間の連携 | 実ユーザー体験 |
| コスト | 低い | 中程度 | 高い |
| 脆さ | 実装詳細に依存しがち | バランスが良い | UIの変更に弱い |
| カバー範囲 | 狭い | 中程度 | 広い |
モダンフロントエンド(TypeScript + React + Next.js)では、テスティングトロフィーが効果的です。
理由:
| レイヤー | 配分目安 | 対象 |
|---|---|---|
| 静的テスト | 100%(基盤) | すべてのコードにTypeScript + ESLintを適用 |
| 結合テスト | 60% | UIコンポーネント、APIレイヤー、状態管理 |
| 単体テスト | 20% | 複雑なビジネスロジック、純粋関数、型ガード |
| E2Eテスト | 10% | ユーザー登録、ログイン、決済などクリティカルフロー |
※プロジェクトの特性により調整してください。純粋なロジックが多い場合は単体テストを増やし、UIが複雑な場合は結合テストを重視します。
一方、バックエンドやライブラリ開発では、ピラミッド型も依然として有効です。
理由1: UIがない = 実装詳細への依存が問題になりにくい
フロントエンドで単体テストが「壊れやすい」と言われるのは、UIの変更(ボタンの位置、コンポーネント構造、スタイリング)が頻繁に起きるためです。一方、バックエンドには「見た目」がないため、公開API(エンドポイントや関数シグネチャ)が安定している限り、内部実装を変えてもテストが壊れにくいという特性があります。
理由2: 純粋関数・ビジネスロジックが多い
バックエンドには以下のような「入力→処理→出力」が明確なロジックが多く存在します:
これらは外部依存なしにテストでき、単体テストで高速かつ網羅的に検証できます。
理由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つの流派があります。テスト戦略の選択と密接に関わるテーマです。
「全部E2Eでテストすれば安心」という考えは非効率です。各テストにはメリット・デメリットがあり、トレードオフを理解して選択することが重要です。
各テストレイヤーのトレードオフ
| テスト | メリット | デメリット |
|---|---|---|
| 静的テスト | コストほぼゼロ、即時フィードバック | 実行時のバグは検出できない |
| 単体テスト | 高速、バグの局所化が容易、網羅しやすい | カバー範囲が狭い、実装詳細に依存しがち |
| 結合テスト | 信頼性とコストのバランス◎、リファクタリング耐性 | セットアップがやや複雑 |
| E2Eテスト | 実ユーザー体験に最も近い、高い信頼性 | 遅い、脆い、メンテナンスコスト高 |
トレードオフの軸
これらのトレードオフを理解し、適切な場所に適切なテストを書くことで、同じ工数でより高い品質保証を実現できます。「このロジックは単体テストで十分か?」「ここは結合テストでユーザー視点の検証が必要か?」と考える習慣が、テスト投資の効率を高めます。
単体テストが実装詳細に依存しすぎると、リファクタリングのたびにテストが壊れます。これは「テストが足かせになる」状態であり、開発速度を低下させます。
実装詳細に依存したテストの例(壊れやすい)
// ❌ 内部実装に依存したテスト
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の実装を変更してもテストが壊れません。テストが壊れるのは、ユーザー体験が変わったときだけになります。
テスト種類ごとに実行タイミングを分けることで、フィードバックループを最適化できます。すべてのテストを毎回実行すると、開発者は結果を待つ時間が長くなり、生産性が低下します。
段階的テスト実行の考え方
| タイミング | 実行するテスト | 目的 | 目安時間 |
|---|---|---|---|
| コミット時(ローカル) | 静的テスト + 変更ファイル関連の単体テスト | 即時フィードバック | 〜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分以内にフィードバックを得られ、開発のリズムを保てます。
テスト戦略の共通言語を持つことで、チーム内の議論がスムーズになります。
コードレビューでの会話例
❌ 曖昧な議論:
「このテスト、もっとちゃんと書いた方がいいのでは?」
「ちゃんとって何ですか...?」
✅ 共通言語がある議論:
「この機能は複数コンポーネントが連携するので、
単体テストより結合テストの方が適切では?」
「確かに、ユーザー視点で振る舞いをテストした方が
リファクタリング耐性も高くなりますね」テスト戦略をドキュメント化するメリット
チームで以下を明文化しておくと効果的です:
## テスト方針(例)
- 静的テスト: 全コードに適用(TypeScript strict + ESLint)
- 単体テスト: 複雑なビジネスロジック、ユーティリティ関数
- 結合テスト: UIコンポーネント、APIハンドラ(メイン)
- E2E: ユーザー登録、ログイン、決済フローのみテスト戦略を見直すことで、「テストを書いているのに不安が消えない」という状況から脱却できます。ぜひ自分のプロジェクトに合った戦略を選んでみてください。