• ホーム
  • ブログ
  • テストダブル完全ガイド:Vitest + React Testing Libraryでの実践

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

鶴田 篤広のプロフィール画像

鶴田 篤広

ソフトウェアエンジニア

作成日:

更新日:

TypeScriptReactVitestTesting Libraryテスト
テストダブル完全ガイド:Vitest + React Testing Libraryでの実践 - Martin Fowlerのテストダブル分類を基に、Vitest + React Testing L...

TL;DR

  • テストダブルはテスト用の代役オブジェクトの総称。Dummy、Fake、Stub、Spy、Mockの5種類がある
  • Vitestでは vi.fn() = Stub/Dummy/Mock、vi.spyOn() = Spy として対応。vi.mock() はモジュール置換機能
  • vi.hoisted() を使うことでESMのホイスティング問題を解決できる
  • テストダブルの概念を理解することで、テストの保守性が向上し、リファクタリング時のテスト修正コストを削減できる
  • 適切なテスト手法の選択と、チーム内のコミュニケーション品質が改善される

この記事の対象読者

  • Vitest/Jestでテストを書いているが、モックの使い分けに悩んでいる方
  • Martin Fowlerのテストダブル分類を実コードで理解したい方
  • React Testing Libraryでのモック戦略を学びたい方

はじめに

vi.mock()vi.fn() の違いがよくわからない...」 「テストが壊れやすくて、ちょっとした変更で大量のテストが失敗する」 「モックって言葉、なんとなく使ってるけど正確な意味は...?」

こんな経験はありませんか?

実は、私たちが普段「モック」と呼んでいるものには、明確な分類があります。Martin Fowlerが紹介した「テストダブル」という概念を理解すると、これらの悩みがスッキリ解決します。

この記事では、テストダブルの5つの分類を学び、Vitest + React Testing Libraryでの具体的な実装パターンを紹介します。概念を理解することで、テストの設計力が格段に向上するはずです。

テストダブルとは

代役

テストダブル(Test Double)とは、テスト目的で本番オブジェクトを置き換える代役オブジェクトの総称です。

この用語は、Gerard Meszarosの著書「xUnit Test Patterns」で提唱され、Martin Fowlerがブログ記事で広めました。「ダブル」は映画のスタントダブル(代役)に由来しています。

なぜ分類が必要なのか?

多くの開発者は「モック」という言葉を、テスト用の代替オブジェクト全般に使っています。しかし、実際には目的や振る舞いによって異なる種類があります。

分類を理解するメリット:

  1. チーム内のコミュニケーションが明確になる - 「ここはStubで固定値を返す」「Spyで呼び出しを確認する」と具体的に伝えられる
  2. 適切なテスト手法を選択できる - 目的に応じた最適なアプローチがわかる
  3. テストの保守性が向上する - 過剰なモックを避け、壊れにくいテストが書ける

5つのテストダブル

Martin Fowler(Gerard Meszarosの分類を引用)によると、テストダブルは以下の5種類に分類されます。

1. Dummy(ダミー)

パラメータを埋めるためだけに使い、実際には呼ばれないオブジェクトです。

// 型定義を満たすためだけに渡す空の関数
const dummyLogger = {
  log: () => {},
  error: () => {},
  warn: () => {},
};
 
// loggerは使われないが、コンストラクタが要求するので渡す
const service = new UserService(repository, dummyLogger);

Dummyは最もシンプルなテストダブルです。テスト対象が依存オブジェクトを使わない場合に、型チェックを通すために使います。

2. Fake(フェイク)

実際に動作する実装を持つが、本番環境には適さないショートカットを使うオブジェクトです。

代表的な例:

  • インメモリデータベース - SQLiteを使ったテスト用DB
  • ローカルファイルストレージ - S3の代わりにローカルディスクを使用
  • インメモリキャッシュ - Redisの代わりにMapを使用
// Fakeの例:インメモリで動作するリポジトリ
class InMemoryUserRepository implements UserRepository {
  private users: Map<number, User> = new Map();
  private nextId = 1;
 
  async create(data: CreateUserInput): Promise<User> {
    const user = { id: this.nextId++, ...data };
    this.users.set(user.id, user);
    return user;
  }
 
  async findById(id: number): Promise<User | null> {
    return this.users.get(id) ?? null;
  }
 
  async findAll(): Promise<User[]> {
    return Array.from(this.users.values());
  }
}

Fakeは他のテストダブルと異なり、実装ロジックを持ちます。テストDBを使った統合テストもFakeパターンの一種です。

Fake自体のテストが必要

Fakeはロジックを持つため、Fake自体にバグがあるとテスト結果が信頼できなくなります。そのため、Fakeに対するテストケースを書くことが重要です。

// Fake自体のテスト
describe('InMemoryUserRepository', () => {
  it('ユーザーを作成して取得できる', async () => {
    const repo = new InMemoryUserRepository();
 
    const created = await repo.create({ name: 'Alice', email: 'alice@example.com' });
    const found = await repo.findById(created.id);
 
    expect(found).toEqual(created);
  });
 
  it('存在しないIDはnullを返す', async () => {
    const repo = new InMemoryUserRepository();
 
    const found = await repo.findById(999);
 
    expect(found).toBeNull();
  });
});

Fakeのテストは、本番の実装と同じ振る舞いをすることを保証するために書きます。これにより、Fakeを使ったテストの信頼性が高まります。

3. Stub(スタブ)

テスト中の呼び出しに対して、あらかじめ決められた応答を返すオブジェクトです。プログラムされた内容以外には応答しません。

// Stubの例:固定値を返すリポジトリ
const stubRepository = {
  findById: vi.fn().mockResolvedValue({
    id: 1,
    name: 'Test User',
    email: 'test@example.com',
  }),
};
 
// テスト対象はこの固定値を受け取る
const user = await userService.getUser(1);
expect(user.name).toBe('Test User');

Stubは「何を返すか」を制御します。外部APIの応答をシミュレートしたり、特定の状態を再現したりするのに使います。

4. Spy(スパイ)

Stubとしての機能に加えて、呼び出し方法に基づいて情報を記録するオブジェクトです。

// Spyの例:既存の実装を監視
const consoleSpy = vi.spyOn(console, 'error');
 
await userService.deleteUser(999); // 存在しないID
 
// 呼び出しを検証
expect(consoleSpy).toHaveBeenCalledWith(
  'User not found:',
  999
);
 
consoleSpy.mockRestore();

Spyは「どう呼ばれたか」を記録します。既存の実装を置き換えずに監視したい場合に使います。

5. Mock(モック)

期待値があらかじめプログラムされ、予期しない呼び出しで例外をスローできるオブジェクトです。

// Mockの例:期待値を検証
const mockMailer = {
  send: vi.fn(),
};
 
await userService.register({
  name: 'New User',
  email: 'new@example.com',
});
 
// 期待通りに呼ばれたか検証
expect(mockMailer.send).toHaveBeenCalledWith({
  to: 'new@example.com',
  subject: 'ようこそ!',
  body: expect.stringContaining('New User'),
});
expect(mockMailer.send).toHaveBeenCalledTimes(1);

Mockは「期待通りに呼ばれたか」を検証します。これは重要な違いで、Stub/Fakeが「何を返すか(状態)」に注目するのに対し、Mockは「どう呼ばれたか(振る舞い)」に注目します。

テストダブルの比較表

種類 目的 実装 検証対象
Dummy 型を満たす なし -
Fake 本番同等の動作 あり 状態
Stub 固定値を返す 戻り値のみ 状態
Spy 呼び出しを記録 既存 + 記録 呼び出し
Mock 期待値を検証 戻り値 + 期待 呼び出し

Vitestでのテストダブル実装

ここからは、Vitestでの具体的な実装パターンを見ていきます。

vi.fn() - Stub/Dummyの作成

vi.fn() は最も基本的なテストダブル作成APIです。

import { describe, expect, it, vi } from 'vitest';
 
describe('UserService', () => {
  it('リポジトリからユーザーを取得できる', async () => {
    // Stub: 固定値を返すモック関数
    const mockRepository = {
      findById: vi.fn().mockResolvedValue({
        id: 1,
        name: 'Test User',
        email: 'test@example.com',
      }),
      // Dummy: 型を満たすだけで呼ばれない
      create: vi.fn(),
      update: vi.fn(),
      delete: vi.fn(),
    };
 
    const service = new UserService(mockRepository);
    const user = await service.getUser(1);
 
    expect(user.name).toBe('Test User');
    expect(mockRepository.findById).toHaveBeenCalledWith(1);
  });
});

よく使うメソッド

// 同期的な戻り値
vi.fn().mockReturnValue('fixed value');
 
// 非同期の戻り値
vi.fn().mockResolvedValue({ data: 'async value' });
 
// エラーをスロー
vi.fn().mockRejectedValue(new Error('Failed'));
 
// カスタム実装
vi.fn().mockImplementation((id) => {
  if (id === 1) return { id: 1, name: 'User 1' };
  return null;
});
 
// 呼び出しごとに異なる値
vi.fn()
  .mockResolvedValueOnce({ page: 1 })
  .mockResolvedValueOnce({ page: 2 });

vi.spyOn() - Spyパターン

vi.spyOn() は既存オブジェクトのメソッドを監視・部分的にモックします。

import { describe, expect, it, vi, afterEach } from 'vitest';
 
describe('ErrorHandler', () => {
  afterEach(() => {
    vi.restoreAllMocks(); // 重要:元の実装に戻す
  });
 
  it('エラー時にconsole.errorが呼ばれる', async () => {
    // 既存の実装を監視(呼び出しを記録)
    const consoleSpy = vi.spyOn(console, 'error')
      .mockImplementation(() => {}); // 出力を抑制
 
    const handler = new ErrorHandler();
    handler.handle(new Error('Something went wrong'));
 
    expect(consoleSpy).toHaveBeenCalledWith(
      '[Error]',
      expect.any(Error)
    );
  });
 
  it('外部ライブラリのメソッドを監視', async () => {
    const fetchSpy = vi.spyOn(global, 'fetch')
      .mockResolvedValue(new Response('OK'));
 
    await apiClient.get('/users');
 
    expect(fetchSpy).toHaveBeenCalledWith(
      expect.stringContaining('/users'),
      expect.any(Object)
    );
  });
});

vi.fn() と vi.spyOn() の違い

項目 vi.fn() vi.spyOn()
対象 新規作成 既存オブジェクト
元の実装 なし 保持可能
リストア 不要 mockRestore()必須
用途 依存の注入 既存の監視

vi.mock() + vi.hoisted() - Mockパターン

3つのAPIの比較

項目 vi.fn() vi.spyOn() vi.mock()
対象 新規作成 既存オブジェクトのメソッド モジュール全体
元の実装 なし 保持可能 置き換え
リストア 不要 mockRestore()必須 自動(テスト終了時)
用途 依存の注入 既存の監視 モジュールレベルの置換
ホイスティング対応 不要 不要 vi.hoisted()推奨

モジュール全体をモックする場合は vi.mock() を使います。

ESMホイスティング問題

この問題、初めて遭遇したときは「なぜundefinedになる?」と混乱された方も多いのではないでしょうか。

ES Modulesでは、インポート文がファイルの先頭に巻き上げ(ホイスト)されます。そのため、以下のコードは動作しません

// ❌ 動作しない例
import { userService } from './user-service';
 
const mockGetUser = vi.fn(); // インポートの後に定義される
 
vi.mock('./user-service', () => ({
  userService: {
    getUser: mockGetUser, // ReferenceError!
  },
}));

vi.hoisted() で解決

vi.hoisted() を使うと、モック定義がインポートより前に実行されます:

import { describe, expect, it, vi } from 'vitest';
 
// ✅ vi.hoisted()でモックを定義(推奨パターン)
const mocks = vi.hoisted(() => ({
  userService: {
    getUser: vi.fn(),
    createUser: vi.fn(),
  },
}));
 
// vi.mock()は自動的にホイストされる
vi.mock('./user-service', () => ({
  userService: mocks.userService,
}));
 
// インポート(実際にはモックが使われる)
import { userController } from './user-controller';
 
describe('UserController', () => {
  it('ユーザーを取得できる', async () => {
    mocks.userService.getUser.mockResolvedValue({
      id: 1,
      name: 'Test User',
    });
 
    const result = await userController.get(1);
 
    expect(result.name).toBe('Test User');
    expect(mocks.userService.getUser).toHaveBeenCalledWith(1);
  });
});

テストデータベース - Fakeパターン

統合テストでは、テスト用データベースを使ったFakeパターンが有効です:

import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { dbClient } from './database';
import { UserRepository } from './user-repository';
 
describe('UserRepository', () => {
  let repository: UserRepository;
 
  beforeEach(async () => {
    repository = new UserRepository(dbClient);
    // テストデータをセットアップ
    await dbClient('users').insert([
      { id: 1, name: 'Alice', email: 'alice@example.com' },
      { id: 2, name: 'Bob', email: 'bob@example.com' },
    ]);
  });
 
  afterEach(async () => {
    // テストデータをクリーンアップ
    await dbClient('users').del();
  });
 
  it('ユーザーを取得できる', async () => {
    const user = await repository.findById(1);
 
    expect(user).toEqual({
      id: 1,
      name: 'Alice',
      email: 'alice@example.com',
    });
  });
 
  it('存在しないユーザーはnullを返す', async () => {
    const user = await repository.findById(999);
 
    expect(user).toBeNull();
  });
});

Vitestでのモックの使い分けや、Testing Libraryのクエリ戦略については、以下の記事でさらに詳しく解説しています。

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

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

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

実践例:React + Testing Libraryでの統合

実際のReactコンポーネントテストでの使い方を見てみましょう。

コンポーネントテストでのモック統合

import { render, screen, waitFor } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
 
// モックをホイスティング
const mocks = vi.hoisted(() => ({
  api: {
    users: {
      list: vi.fn(),
      create: vi.fn(),
    },
  },
}));
 
vi.mock('../api/client', () => ({
  apiClient: mocks.api,
}));
 
import { UserListPage } from './UserListPage';
 
describe('UserListPage', () => {
  it('ユーザー一覧が表示される', async () => {
    // Stub: 固定のユーザーリストを返す
    mocks.api.users.list.mockResolvedValue([
      { id: 1, name: 'Alice', email: 'alice@example.com' },
      { id: 2, name: 'Bob', email: 'bob@example.com' },
    ]);
 
    render(<UserListPage />);
 
    await waitFor(() => {
      expect(screen.getByText('Alice')).toBeInTheDocument();
      expect(screen.getByText('Bob')).toBeInTheDocument();
    });
  });
 
  it('新規ユーザーを作成できる', async () => {
    mocks.api.users.list.mockResolvedValue([]);
    mocks.api.users.create.mockResolvedValue({
      id: 1,
      name: 'New User',
      email: 'new@example.com',
    });
 
    render(<UserListPage />);
 
    // フォームに入力
    await userEvent.type(
      screen.getByLabelText('名前'),
      'New User'
    );
    await userEvent.type(
      screen.getByLabelText('メール'),
      'new@example.com'
    );
 
    // 送信
    await userEvent.click(
      screen.getByRole('button', { name: '作成' })
    );
 
    // Mock: 期待通りに呼ばれたか検証
    await waitFor(() => {
      expect(mocks.api.users.create).toHaveBeenCalledWith({
        name: 'New User',
        email: 'new@example.com',
      });
    });
  });
});

カスタムレンダー関数の活用

テストユーティリティを作成して、Providerのセットアップを共通化します:

// test-utils.tsx
import { render as originalRender } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 
export const render = (ui: React.ReactElement) => {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: { retry: false },
    },
  });
 
  return originalRender(
    <QueryClientProvider client={queryClient}>
      {ui}
    </QueryClientProvider>
  );
};
 
export * from '@testing-library/react';
// 使用例
import { render, screen } from './test-utils';
 
it('コンポーネントがレンダリングされる', () => {
  render(<UserProfile userId={1} />);
  // Providerが自動的にセットアップされる
});

テストダブルを知るメリット

1. チームコミュニケーションの明確化

レビューやペアプロで「ここはStubで固定値を返してる」「このSpyで呼び出し回数を確認してる」と具体的に伝えられるため、認識齟齬が減ります。

2. 適切なテスト手法の選択

シナリオ 推奨
戻り値を制御したい Stub
呼び出しを確認したい Spy / Mock
実際のDB処理をテストしたい Fake
型を満たすだけ Dummy

3. テストの保守性向上

「モックしすぎ」を避けることで、リファクタリング耐性が高まります。

// ❌ 過剰なモック:実装詳細に依存
vi.mock('./utils', () => ({
  formatDate: vi.fn().mockReturnValue('2024-01-01'),
  parseDate: vi.fn(),
  validateDate: vi.fn().mockReturnValue(true),
}));
 
// ✅ 必要最小限のモック:振る舞いに注目
vi.mock('./api-client', () => ({
  fetchUser: vi.fn().mockResolvedValue({ id: 1, name: 'Test' }),
}));

4. テストピラミッドの実践

  • 単体テスト: Stub/Mockで依存を分離
  • 統合テスト: Fakeで実際の処理をテスト
  • E2Eテスト: テストダブルなし

ベストプラクティス

使い分けフローチャート

依存オブジェクトが必要?
├─ NO → Dummy(空実装)
└─ YES
    └─ 実際の動作が必要?
        ├─ YES → Fake(テストDB等)
        └─ NO
            └─ 呼び出しを確認したい?
                ├─ NO → Stub(固定値を返す)
                └─ YES
                    └─ 既存オブジェクトを使う?
                        ├─ YES → Spy(vi.spyOn)
                        └─ NO → Mock(vi.fn + 検証)

アンチパターンを避ける

// ❌ テストごとにモックをリセットしない
// → 他のテストに影響する
 
// ✅ 各テスト後にリセット
afterEach(() => {
  vi.resetAllMocks();    // モックの呼び出し履歴をクリア
  vi.restoreAllMocks();  // vi.spyOn()の元の実装を復元
});

毎回 afterEach を書くのは面倒なので、vitest.config.ts でグローバルに設定できます:

// vitest.config.ts
import { defineConfig } from 'vitest/config';
 
export default defineConfig({
  test: {
    // 各テスト後に自動でモックをリセット
    mockReset: true,      // vi.resetAllMocks() と同等
    restoreMocks: true,   // vi.restoreAllMocks() と同等
 
    // clearMocks: true,  // vi.clearAllMocks() と同等(呼び出し履歴のみクリア)
  },
});
オプション 対応するAPI 効果
clearMocks vi.clearAllMocks() 呼び出し履歴をクリア
mockReset vi.resetAllMocks() 履歴クリア + 戻り値を undefined
restoreMocks vi.restoreAllMocks() vi.spyOn() の元の実装を復元

これにより、すべてのテストファイルで自動的にモックがリセットされ、テスト間の干渉を防げます。

// ❌ 実装詳細をモックする
vi.mock('./internal-helper', ...);
 
// ✅ 境界(API、DB)をモックする
vi.mock('./api-client', ...);

おわりに

この記事では、Martin Fowlerが紹介したテストダブルの5分類を、Vitestでの実装と組み合わせて解説しました。

「モック」という曖昧な言葉ではなく、Stub、Spy、Mockを使い分けることで、テストコードの意図が明確になります。また、vi.hoisted()のようなVitestの機能を理解することで、ESM時代のテスト実装がスムーズになるはずです。

テストは「書いて終わり」ではなく、保守し続けるものです。この記事で紹介したベストプラクティスを活用して、変化に強いテストコードを目指しましょう。

まとめ

テストダブルの5つの分類を理解することで、Vitestでのテストがより明確になります。

種類 Vitest API 用途
Dummy vi.fn() 型を満たすだけ
Stub vi.fn().mockReturnValue() 固定値を返す
Spy vi.spyOn() 既存を監視
Mock vi.fn() + 呼び出し検証 期待値を検証
Fake テストDB / インメモリ実装 簡易実装

vi.mock() はテストダブルの分類ではなく、モジュール全体を置き換える機能です。

覚えておくポイント:

  1. vi.hoisted() でホイスティング問題を解決
  2. 最小限のモックで保守性を高める
  3. 境界をモックし、実装詳細は避ける
  4. afterEachでリセット・リストアを忘れずに

テストダブルの概念を理解することで、「なぜこのテストが壊れやすいのか」「どこをモックすべきか」が明確になります。ぜひ日々のテスト実装に活かしてください。

参考文献

こんなお悩みはありませんか?

1

システムの改修・刷新

古いシステムを使い続けているが、そろそろ限界を感じている

2

技術の相談相手がいない

社内にエンジニアがおらず、技術的な判断を相談できる人がいない

3

新規サービスを小さく始めたい

アイデアはあるが、まずは最小限の形で試してみたい

4

業務の効率化・自動化

手作業やExcel管理から脱却し、業務をシステム化したい

5

AIを活用したい

ChatGPTなどのAIを業務に取り入れたいが、どう始めればいいかわからない

このようなお悩みをお持ちの企業様に、
クレインテックが伴走支援いたします。

初回のご相談・お見積もりは無料です。

この記事をシェア

クレインテックに相談する

お客様と一緒に課題を整理し、小さく始めて育てる「共創型開発」を行っています。

「こんなシステムは作れる?」「費用感を知りたい」など、どんな段階でもお気軽にご相談ください。

初回のご相談は無料です。

お問い合わせ