T-CREATOR

Jest の自動モック機能と手動モックの違い

Jest の自動モック機能と手動モックの違い

テストコードを書いていると、外部依存関係や複雑なロジックを扱う場面に必ず遭遇します。そんな時、Jest のモック機能が救世主となってくれるのですが、自動モックと手動モックの使い分けに悩んだ経験はありませんか?

この記事では、Jest の自動モック機能と手動モックの違いを実践的な観点から詳しく解説します。どちらを選ぶべきか、どのような場面で使い分けるべきかを理解することで、より効率的で保守性の高いテストコードを書けるようになるでしょう。

Jest のモック機能とは

モックの定義と目的

モックとは、実際のオブジェクトや関数の代わりに使用される模擬的な実装のことです。テストにおいて、外部 API やデータベース、ファイルシステムなど、テスト環境で制御が困難な依存関係を置き換えるために使用されます。

モックの主な目的は以下の通りです:

  • テストの独立性を保つ: 外部依存関係に影響されない安定したテスト
  • テストの高速化: 重い処理を軽量な模擬実装に置き換える
  • エッジケースのテスト: 通常では発生しにくいエラー状況を再現する
  • コードの分離: テスト対象のコードを他の部分から分離する

テストにおけるモックの役割

実際の開発現場では、以下のような場面でモックが活躍します:

typescript// 外部APIを呼び出すサービスクラス
class UserService {
  async getUser(id: string) {
    const response = await fetch(`/api/users/${id}`);
    return response.json();
  }
}

// このような外部依存があるコードをテストする際にモックが必要

Jest でのモック実装方法

Jest では、モジュールレベルと関数レベルの両方でモックを実装できます。基本的な実装方法を見てみましょう:

typescript// モジュール全体をモック化
jest.mock('./userService');

// 特定の関数のみをモック化
jest.fn().mockReturnValue('mocked value');

// 既存の関数をスパイとして監視
jest.spyOn(console, 'log');

自動モック機能の仕組み

jest.mock()の基本動作

自動モックは、jest.mock()を使用することで、モジュール全体を自動的にモック化する機能です。最もシンプルな実装方法の一つです。

typescript// 自動モックの基本例
jest.mock('./apiClient');

import { ApiClient } from './apiClient';

describe('UserService', () => {
  it('should fetch user data', async () => {
    // ApiClientは自動的にモック化されている
    const mockApiClient =
      ApiClient as jest.Mocked<ApiClient>;
    mockApiClient.get.mockResolvedValue({
      id: '1',
      name: 'Test User',
    });

    const userService = new UserService();
    const result = await userService.getUser('1');

    expect(result.name).toBe('Test User');
  });
});

自動モックが適用される条件

自動モックは以下の条件で適用されます:

  • ES6 モジュール: import​/​exportを使用したモジュール
  • CommonJS モジュール: require​/​module.exportsを使用したモジュール
  • デフォルトエクスポート: モジュールのデフォルトエクスポート
  • 名前付きエクスポート: 個別にエクスポートされた関数やクラス
typescript// 自動モックが適用される例
jest.mock('./utils'); // ユーティリティ関数
jest.mock('./services/api'); // APIサービス
jest.mock('axios'); // 外部ライブラリ

自動モックの特徴と制限

自動モックには以下のような特徴があります:

特徴:

  • 実装が簡単で、最小限のコードでモック化できる
  • モジュール全体が自動的にモック化される
  • デフォルトで安全な値(空の関数、空のオブジェクト)が返される

制限:

  • 細かい制御が困難
  • 複雑な戻り値の設定には追加の設定が必要
  • モックの実装詳細が隠蔽される
typescript// 自動モックの制限例
jest.mock('./complexService');

import { ComplexService } from './complexService';

describe('ComplexService Test', () => {
  it('should handle complex logic', () => {
    const service = new ComplexService();

    // 自動モックでは、メソッドは空の関数として実装される
    // 複雑な戻り値や副作用の制御が困難
    const result = service.processData({
      id: 1,
      data: 'test',
    });

    expect(result).toBeUndefined(); // 自動モックではundefinedが返される
  });
});

手動モックの実装方法

jest.fn()を使った関数モック

手動モックでは、jest.fn()を使用して関数を明示的にモック化します。これにより、戻り値や呼び出し回数などを細かく制御できます。

typescript// jest.fn()を使った基本的な関数モック
describe('Manual Mock Example', () => {
  it('should mock function with jest.fn()', () => {
    // 関数をモック化
    const mockFunction = jest.fn();

    // 戻り値を設定
    mockFunction.mockReturnValue('mocked result');

    // 関数を実行
    const result = mockFunction();

    expect(result).toBe('mocked result');
    expect(mockFunction).toHaveBeenCalledTimes(1);
  });
});

非同期関数のモック化も簡単に行えます:

typescript// 非同期関数のモック化
describe('Async Function Mock', () => {
  it('should mock async function', async () => {
    const mockAsyncFunction = jest.fn();

    // Promiseを返すように設定
    mockAsyncFunction.mockResolvedValue({
      success: true,
      data: 'test',
    });

    const result = await mockAsyncFunction();

    expect(result.success).toBe(true);
    expect(result.data).toBe('test');
  });
});

jest.spyOn()によるスパイ機能

jest.spyOn()は、既存のオブジェクトのメソッドを監視し、必要に応じてモック化する機能です。元の実装を保持しながら、呼び出し情報を取得できます。

typescript// jest.spyOn()を使ったスパイ機能
describe('Spy Example', () => {
  it('should spy on object method', () => {
    const user = {
      name: 'John',
      greet() {
        return `Hello, ${this.name}!`;
      },
    };

    // greetメソッドをスパイとして監視
    const spy = jest.spyOn(user, 'greet');

    const result = user.greet();

    expect(result).toBe('Hello, John!');
    expect(spy).toHaveBeenCalledTimes(1);

    // スパイを復元
    spy.mockRestore();
  });
});

エラーハンドリングのテストにも活用できます:

typescript// エラーを投げる関数のスパイ
describe('Error Handling Spy', () => {
  it('should spy on function that throws error', () => {
    const errorFunction = () => {
      throw new Error('Test error');
    };

    const spy = jest
      .spyOn(console, 'error')
      .mockImplementation(() => {});

    try {
      errorFunction();
    } catch (error) {
      console.error('Caught error:', error.message);
    }

    expect(spy).toHaveBeenCalledWith(
      'Caught error:',
      'Test error'
    );
    spy.mockRestore();
  });
});

カスタムモックオブジェクトの作成

複雑なオブジェクトやクラスをモック化する場合、カスタムモックオブジェクトを作成します。

typescript// カスタムモックオブジェクトの作成
describe('Custom Mock Object', () => {
  it('should create custom mock object', () => {
    // 複雑なオブジェクトのモック化
    const mockApiClient = {
      get: jest
        .fn()
        .mockResolvedValue({ id: 1, name: 'Test' }),
      post: jest.fn().mockResolvedValue({ success: true }),
      put: jest.fn().mockResolvedValue({ updated: true }),
      delete: jest
        .fn()
        .mockResolvedValue({ deleted: true }),
    };

    // モックオブジェクトを使用
    expect(mockApiClient.get).toBeDefined();
    expect(mockApiClient.post).toBeDefined();
  });
});

クラスのモック化も可能です:

typescript// クラスのモック化
class DatabaseService {
  async connect() {
    // 実際のデータベース接続処理
  }

  async query(sql: string) {
    // 実際のクエリ実行処理
  }
}

describe('Database Service Mock', () => {
  it('should mock database service', async () => {
    // クラスのメソッドをモック化
    const mockDatabaseService = {
      connect: jest.fn().mockResolvedValue(true),
      query: jest
        .fn()
        .mockResolvedValue([{ id: 1, name: 'User' }]),
    };

    const result = await mockDatabaseService.query(
      'SELECT * FROM users'
    );

    expect(result).toEqual([{ id: 1, name: 'User' }]);
    expect(mockDatabaseService.query).toHaveBeenCalledWith(
      'SELECT * FROM users'
    );
  });
});

自動モック vs 手動モックの比較

実装の複雑さ

自動モック:

  • 実装が非常にシンプル
  • 1 行のコードでモジュール全体をモック化
  • 学習コストが低い
typescript// 自動モック - シンプルな実装
jest.mock('./externalService');

手動モック:

  • より詳細な設定が必要
  • 各メソッドやプロパティを個別に定義
  • より多くのコードを書く必要がある
typescript// 手動モック - 詳細な設定が必要
const mockService = {
  method1: jest.fn().mockReturnValue('value1'),
  method2: jest.fn().mockResolvedValue('value2'),
  property: 'test',
};

パフォーマンスの違い

自動モック:

  • モジュール全体をモック化するため、メモリ使用量が大きい場合がある
  • 初期化時のオーバーヘッドが比較的大きい

手動モック:

  • 必要な部分のみをモック化するため、メモリ効率が良い
  • 実行時のパフォーマンスが優れている
typescript// パフォーマンス比較の例
describe('Performance Comparison', () => {
  it('should compare performance', () => {
    // 自動モック - モジュール全体をモック化
    jest.mock('./largeModule'); // 大きなモジュール全体がモック化される

    // 手動モック - 必要な部分のみモック化
    const mockFunction = jest.fn(); // 必要な関数のみモック化
  });
});

柔軟性と制御性

自動モック:

  • 基本的なモック化は簡単
  • 複雑な戻り値や条件分岐の制御が困難
  • モックの実装詳細が隠蔽される

手動モック:

  • 細かい制御が可能
  • 動的な戻り値の設定
  • 呼び出し回数や引数の詳細な検証
typescript// 柔軟性の比較例
describe('Flexibility Comparison', () => {
  it('should demonstrate flexibility differences', () => {
    // 自動モック - 制限された制御
    jest.mock('./service');
    const service = require('./service');
    // 基本的なモック化のみ可能

    // 手動モック - 高度な制御
    const mockService = {
      method: jest
        .fn()
        .mockReturnValueOnce('first call')
        .mockReturnValueOnce('second call')
        .mockRejectedValueOnce(new Error('error')),
    };

    // 呼び出しごとに異なる戻り値を設定可能
  });
});

メンテナンス性

自動モック:

  • モジュールの変更に自動で対応
  • メンテナンスコストが低い
  • ただし、モックの動作が予測しにくい場合がある

手動モック:

  • 明示的な実装のため、動作が予測しやすい
  • モジュールの変更時に手動で更新が必要
  • テストコードの可読性が高い
typescript// メンテナンス性の例
describe('Maintainability Example', () => {
  it('should show maintainability differences', () => {
    // 自動モック - 変更に自動対応
    jest.mock('./api');
    // APIモジュールが変更されても自動で対応

    // 手動モック - 明示的な更新が必要
    const mockApi = {
      get: jest.fn(),
      post: jest.fn(),
      // 新しいメソッドが追加された場合、手動で追加が必要
    };
  });
});

実際の使用場面と選択基準

自動モックが適している場合

自動モックは以下のような場面で効果的です:

1. プロトタイピングや初期開発段階

typescript// 開発初期の簡単なテスト
jest.mock('./database');
jest.mock('./externalApi');

describe('UserService', () => {
  it('should create user', async () => {
    // 詳細な実装は後回しにして、基本的な動作を確認
    const userService = new UserService();
    const result = await userService.createUser({
      name: 'Test',
    });
    expect(result).toBeDefined();
  });
});

2. 外部ライブラリのモック化

typescript// 外部ライブラリの自動モック
jest.mock('axios');
jest.mock('lodash');

describe('External Library Test', () => {
  it('should use external libraries', () => {
    // 外部ライブラリの詳細な実装を気にせずテスト可能
  });
});

3. 大規模なモジュールの全体モック

typescript// 大きなモジュール全体をモック化
jest.mock('./complexBusinessLogic');

describe('Business Logic Test', () => {
  it('should test business logic', () => {
    // 複雑なビジネスロジック全体をモック化
  });
});

手動モックが適している場合

手動モックは以下のような場面で効果的です:

1. 細かい制御が必要な場合

typescript// 詳細な制御が必要なテスト
describe('Detailed Control Test', () => {
  it('should test with detailed control', () => {
    const mockValidator = {
      validate: jest
        .fn()
        .mockReturnValueOnce(true)
        .mockReturnValueOnce(false)
        .mockImplementation((input) => input.length > 0),
    };

    // 呼び出しごとに異なる動作を設定
    expect(mockValidator.validate('test')).toBe(true);
    expect(mockValidator.validate('')).toBe(false);
  });
});

2. エラーハンドリングのテスト

typescript// エラーケースの詳細なテスト
describe('Error Handling Test', () => {
  it('should handle specific errors', () => {
    const mockApi = {
      fetchData: jest
        .fn()
        .mockRejectedValueOnce(new Error('Network error'))
        .mockRejectedValueOnce(new Error('Timeout error')),
    };

    // 特定のエラーケースを再現
  });
});

3. 複雑な戻り値や副作用のテスト

typescript// 複雑な戻り値のテスト
describe('Complex Return Value Test', () => {
  it('should test complex return values', () => {
    const mockCalculator = {
      calculate: jest
        .fn()
        .mockImplementation((a, b, operation) => {
          switch (operation) {
            case 'add':
              return a + b;
            case 'subtract':
              return a - b;
            case 'multiply':
              return a * b;
            default:
              throw new Error('Unknown operation');
          }
        }),
    };

    // 複雑なロジックを含む戻り値をテスト
  });
});

プロジェクト規模による選択

小規模プロジェクト(1-3 人)

  • 自動モックを中心に使用
  • シンプルな実装で十分
  • 学習コストを抑える
typescript// 小規模プロジェクトでの自動モック活用
jest.mock('./services');
jest.mock('./utils');

describe('Small Project Test', () => {
  it('should work with auto mocks', () => {
    // シンプルなテストで十分
  });
});

中規模プロジェクト(4-10 人)

  • 自動モックと手動モックの併用
  • 重要な部分は手動モックで詳細制御
  • チーム全体での一貫性を保つ
typescript// 中規模プロジェクトでの併用
jest.mock('./externalServices'); // 外部サービスは自動モック

describe('Medium Project Test', () => {
  it('should use both auto and manual mocks', () => {
    // 重要なビジネスロジックは手動モック
    const mockBusinessLogic = {
      process: jest.fn().mockImplementation((data) => {
        // 複雑なビジネスロジックのモック
        return data.map((item) => ({
          ...item,
          processed: true,
        }));
      }),
    };
  });
});

大規模プロジェクト(10 人以上)

  • 手動モックを中心に使用
  • 詳細なテストケースの実装
  • テストの保守性を重視
typescript// 大規模プロジェクトでの手動モック活用
describe('Large Project Test', () => {
  beforeEach(() => {
    // 詳細なモック設定
    const mockDatabase = {
      connect: jest.fn().mockResolvedValue(true),
      query: jest.fn().mockImplementation((sql, params) => {
        // SQLクエリに応じた詳細なモック実装
        if (sql.includes('SELECT')) {
          return Promise.resolve([{ id: 1, name: 'Test' }]);
        }
        return Promise.resolve({ affectedRows: 1 });
      }),
      disconnect: jest.fn().mockResolvedValue(true),
    };

    // グローバルなモック設定
    global.database = mockDatabase;
  });
});

まとめ

Jest の自動モック機能と手動モックは、それぞれに適した使用場面があります。選択のポイントを整理すると:

自動モックを選ぶべき場合:

  • プロトタイピングや初期開発段階
  • 外部ライブラリのモック化
  • 大規模なモジュールの全体モック
  • シンプルなテストケース

手動モックを選ぶべき場合:

  • 細かい制御が必要なテスト
  • エラーハンドリングの詳細なテスト
  • 複雑な戻り値や副作用のテスト
  • 大規模プロジェクトでの保守性重視

重要なのは、プロジェクトの規模や要件に応じて適切な選択をすることです。また、チーム全体で一貫した方針を持つことで、テストコードの可読性と保守性を向上させることができます。

Jest のモック機能を効果的に活用することで、より信頼性の高いテストコードを書くことができるでしょう。どちらの方法を選ぶにしても、テストの目的とプロジェクトの要件をしっかりと理解した上で判断することが大切です。

関連リンク