T-CREATOR

Jest モック(mock)関数の基本と活用パターン

Jest モック(mock)関数の基本と活用パターン

Jest でテストを書いていると、必ずと言っていいほど遭遇するのがモック機能です。外部 API の呼び出し、ファイルシステムへのアクセス、時間に依存する処理など、実際のアプリケーションには様々な外部依存関係が存在します。これらをテスト環境で適切に制御するために、モック機能は欠かせない存在となっています。

この記事では、Jest のモック機能を段階的に学習し、実践的な活用パターンまでマスターできるよう詳しく解説いたします。基本的な概念から高度なテクニックまで、体系的に理解して開発現場で自信を持って活用できるようになりましょう。

Jest モック機能の背景

モック機能が必要な理由

現代のアプリケーション開発では、単一の機能が完全に独立して動作することは稀です。データベースアクセス、Web API の呼び出し、ファイル操作、外部ライブラリの使用など、様々な依存関係が存在しています。

外部依存関係の問題

テストを実行するたびに実際の API を呼び出したり、データベースに接続したりすると、以下のような問題が発生します:

  1. 実行速度の低下 - ネットワーク通信や I/O 処理により、テストの実行時間が大幅に増加
  2. 不安定性 - 外部サービスの状態に依存するため、テストが不安定になる
  3. 副作用 - データの変更や外部リソースへの影響が発生する可能性
  4. 環境依存 - 特定の環境でのみテストが動作する問題
javascript// 問題のあるテスト例(外部依存あり)
describe('User API', () => {
  it('should fetch user data', async () => {
    // 実際のAPIを呼び出してしまう
    const response = await fetch(
      'https://api.example.com/users/1'
    );
    const user = await response.json();

    expect(user.name).toBe('John Doe');
    // このテストは外部APIの状態に依存してしまう
  });
});

制御可能なテスト環境の重要性

モック機能を使用することで、これらの問題を解決できます。外部依存関係を制御可能な偽の実装に置き換えることで、テストの実行速度、安定性、独立性を確保できるのです。

javascript// モックを使用した改良版
describe('User API', () => {
  it('should fetch user data', async () => {
    // fetchをモック化
    global.fetch = jest.fn().mockResolvedValue({
      json: jest.fn().mockResolvedValue({
        name: 'John Doe',
      }),
    });

    const response = await fetch(
      'https://api.example.com/users/1'
    );
    const user = await response.json();

    expect(user.name).toBe('John Doe');
    // 外部依存なしで安定したテストが可能
  });
});

テスト分離の重要性

単体テストの基本原則の一つは、テスト対象の機能を他の依存関係から分離することです。これにより、テストが失敗した際の原因を明確に特定できるようになります。

純粋な単体テストの実現

モック機能を適切に使用することで、テスト対象のコードだけに焦点を当てた純粋な単体テストを実現できます。これは、バグの早期発見と修正効率の向上に直結するでしょう。

javascript// ユーザーサービスの例
class UserService {
  constructor(apiClient, logger) {
    this.apiClient = apiClient;
    this.logger = logger;
  }

  async createUser(userData) {
    try {
      this.logger.info('Creating user:', userData);
      const result = await this.apiClient.post(
        '/users',
        userData
      );
      this.logger.info('User created successfully');
      return result;
    } catch (error) {
      this.logger.error('Failed to create user:', error);
      throw new Error('User creation failed');
    }
  }
}

// 分離されたテスト
describe('UserService', () => {
  let userService;
  let mockApiClient;
  let mockLogger;

  beforeEach(() => {
    mockApiClient = {
      post: jest.fn(),
    };
    mockLogger = {
      info: jest.fn(),
      error: jest.fn(),
    };
    userService = new UserService(
      mockApiClient,
      mockLogger
    );
  });

  it('should create user successfully', async () => {
    const userData = {
      name: 'John',
      email: 'john@example.com',
    };
    const expectedResult = { id: 1, ...userData };

    mockApiClient.post.mockResolvedValue(expectedResult);

    const result = await userService.createUser(userData);

    expect(result).toEqual(expectedResult);
    expect(mockLogger.info).toHaveBeenCalledWith(
      'Creating user:',
      userData
    );
    expect(mockLogger.info).toHaveBeenCalledWith(
      'User created successfully'
    );
  });
});

依存関係の明確化

モックを使用することで、テスト対象のコードがどのような依存関係を持っているかが明確になります。これは、コードの設計改善にも役立つ副次的な効果があります。

#利点説明
1実行速度向上外部通信を排除し、テスト実行時間を短縮
2安定性確保外部要因による不安定性を排除
3独立性保証他のシステムに依存しないテスト環境を構築
4副作用防止実際のデータやシステムへの影響を回避
5エラーケーステスト意図的にエラーを発生させてテスト可能

基本モック機能の課題

外部依存関係のテスト困難

実際のプロジェクトでは、様々な種類の外部依存関係が存在し、それぞれ異なるアプローチでのモック化が必要になります。

API クライアントライブラリの課題

多くのアプリケーションでは、axios や fetch などの HTTP クライアントライブラリを使用しています。これらの動作をテスト環境で再現するのは複雑な作業です。

javascript// 複雑なAPI呼び出しの例
import axios from 'axios';

export class ProductService {
  async getProducts(filters = {}) {
    const params = new URLSearchParams(filters);
    const response = await axios.get(
      `/api/products?${params}`
    );

    if (response.status !== 200) {
      throw new Error(`API error: ${response.status}`);
    }

    return response.data.products.map((product) => ({
      id: product.id,
      name: product.name,
      price: product.price / 100, // 円換算
    }));
  }
}

このような複雑な処理をテストするには、axios の動作を正確にモックする必要があります。

データベース接続の複雑性

データベースを使用するアプリケーションでは、接続の確立、クエリの実行、結果の処理など、多くの段階でモック化が必要になります。

javascript// データベース操作の例
export class UserRepository {
  constructor(database) {
    this.db = database;
  }

  async findByEmail(email) {
    const connection = await this.db.getConnection();
    try {
      const result = await connection.query(
        'SELECT * FROM users WHERE email = ?',
        [email]
      );
      return result.rows[0] || null;
    } finally {
      connection.release();
    }
  }
}

副作用を伴う処理のテスト

副作用を伴う処理のテストは、特に注意深いモック設計が必要です。ファイルシステムの操作、ログ出力、メール送信などがこれに該当します。

ファイルシステム操作の課題

javascriptimport fs from 'fs/promises';
import path from 'path';

export class ConfigManager {
  constructor(configDir = './config') {
    this.configDir = configDir;
  }

  async saveConfig(name, config) {
    const filePath = path.join(
      this.configDir,
      `${name}.json`
    );
    const data = JSON.stringify(config, null, 2);

    // ディレクトリが存在しない場合は作成
    await fs.mkdir(this.configDir, { recursive: true });
    await fs.writeFile(filePath, data, 'utf8');

    return filePath;
  }
}

このような処理をテストする際、実際のファイルシステムを操作するのは適切ではありません。テスト実行のたびにファイルが作成され、テスト環境が汚染される可能性があります。

時間依存処理の制御

現在時刻に依存する処理も、モック化が重要な領域です。

javascriptexport class SessionManager {
  createSession(userId, expirationMinutes = 30) {
    const now = new Date();
    const expiresAt = new Date(
      now.getTime() + expirationMinutes * 60 * 1000
    );

    return {
      id: this.generateSessionId(),
      userId,
      createdAt: now.toISOString(),
      expiresAt: expiresAt.toISOString(),
    };
  }

  generateSessionId() {
    return Math.random().toString(36).substr(2, 9);
  }
}

このようなコードをテストする場合、時間の経過やランダム値の生成を制御する必要があります。

段階的習得アプローチ

基本から応用への学習ステップ

Jest のモック機能を効率的に習得するため、以下の段階的なアプローチを推奨いたします。

ステップ 1: 基本的な関数モック

最初は、簡単な関数のモック化から始めましょう。jest.fn() を使用した基本的なモック関数の作成と検証方法を学びます。

ステップ 2: モジュール全体のモック

次に、jest.mock() を使用したモジュール全体のモック化を学習します。外部ライブラリやカスタムモジュールの置き換え方法を習得します。

ステップ 3: 部分的なモック

jest.spyOn() を使用して、既存のオブジェクトの一部だけをモック化する手法を学びます。

ステップ 4: 時間制御とタイマー

jest.useFakeTimers() を使用した時間に依存する処理のテスト方法を習得します。

ステップ 5: 高度なモックパターン

カスタムモック実装、条件付きモック、動的モック生成などの応用技術を学習します。

実践的な活用段階の設計

各学習段階で実際のプロジェクトに応用できるよう、段階的に複雑なシナリオに取り組んでいきます。

javascript// 学習段階に応じたテスト対象の例

// ステップ1: シンプルな関数
function calculateTax(price, rate) {
  return price * rate;
}

// ステップ2: 外部依存を持つ関数
import { apiClient } from './api-client';

async function fetchUserProfile(userId) {
  return await apiClient.get(`/users/${userId}`);
}

// ステップ3: クラスベースの複雑な処理
class NotificationService {
  constructor(emailService, smsService) {
    this.emailService = emailService;
    this.smsService = smsService;
  }

  async sendWelcomeMessage(user) {
    if (user.preferences.email) {
      await this.emailService.send(user.email, 'Welcome!');
    }
    if (user.preferences.sms) {
      await this.smsService.send(
        user.phone,
        'Welcome to our service!'
      );
    }
  }
}

詳細機能解説と実践例

jest.fn() の基本使用法

jest.fn() は Jest の最も基本的なモック機能で、モック関数を作成するために使用します。この関数は呼び出し回数、引数、戻り値などを追跡し、テストで検証できるようにします。

基本的なモック関数の作成

javascriptdescribe('jest.fn() の基本', () => {
  it('should create mock function', () => {
    const mockFn = jest.fn();

    mockFn('arg1', 'arg2');
    mockFn('arg3');

    // 呼び出し回数の検証
    expect(mockFn).toHaveBeenCalledTimes(2);

    // 特定の引数での呼び出し検証
    expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
    expect(mockFn).toHaveBeenLastCalledWith('arg3');
  });
});

戻り値の制御

モック関数の戻り値を制御することで、様々なシナリオをテストできます。

javascriptdescribe('戻り値の制御', () => {
  it('should return predefined values', () => {
    const mockFn = jest.fn();

    // 固定値を返す
    mockFn.mockReturnValue('固定値');
    expect(mockFn()).toBe('固定値');

    // 一度だけ特定の値を返す
    mockFn.mockReturnValueOnce('一回限り');
    expect(mockFn()).toBe('一回限り');
    expect(mockFn()).toBe('固定値'); // デフォルトに戻る
  });

  it('should handle async return values', async () => {
    const mockAsyncFn = jest.fn();

    // Promise を返す
    mockAsyncFn.mockResolvedValue('成功');
    const result = await mockAsyncFn();
    expect(result).toBe('成功');

    // エラーを投げる
    mockAsyncFn.mockRejectedValue(new Error('失敗'));
    await expect(mockAsyncFn()).rejects.toThrow('失敗');
  });
});

実装の提供

より複雑な動作が必要な場合は、モック関数に実装を提供できます。

javascriptdescribe('カスタム実装', () => {
  it('should execute custom implementation', () => {
    const mockCalculator = jest.fn((a, b) => a + b);

    const result = mockCalculator(2, 3);
    expect(result).toBe(5);
    expect(mockCalculator).toHaveBeenCalledWith(2, 3);
  });

  it('should handle conditional logic', () => {
    const mockValidator = jest.fn((input) => {
      if (typeof input === 'string' && input.length > 0) {
        return { valid: true };
      }
      return { valid: false, error: 'Invalid input' };
    });

    expect(mockValidator('test')).toEqual({ valid: true });
    expect(mockValidator('')).toEqual({
      valid: false,
      error: 'Invalid input',
    });
  });
});

jest.mock() によるモジュールモック

jest.mock() は、モジュール全体をモック化するための機能です。外部ライブラリや自作モジュールを置き換えて、テスト環境で制御可能にできます。

外部ライブラリのモック

javascript// axios のモック例
import axios from 'axios';
import { UserService } from './UserService';

jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;

describe('UserService with axios mock', () => {
  beforeEach(() => {
    mockedAxios.get.mockClear();
  });

  it('should fetch user data via axios', async () => {
    const userData = { id: 1, name: 'John Doe' };
    mockedAxios.get.mockResolvedValue({ data: userData });

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

    expect(result).toEqual(userData);
    expect(mockedAxios.get).toHaveBeenCalledWith('/api/users/1');
  });

  it('should handle axios errors', async () => {
    mockedAxios.get.mockRejectedValue(
      new Error('Network Error')
    );

    const userService = new UserService();

    await expect(userService.getUser(1)).rejects.toThrow(
      'Failed to fetch user'
    );
  });
});

カスタムモジュールのモック

javascript// utils/logger.js のモック
import { processData } from './dataProcessor';

jest.mock('./utils/logger', () => ({
  info: jest.fn(),
  error: jest.fn(),
  warn: jest.fn(),
}));

describe('Data Processor', () => {
  it('should log processing steps', async () => {
    const logger = require('./utils/logger');

    await processData(['item1', 'item2']);

    expect(logger.info).toHaveBeenCalledWith(
      'Processing started'
    );
    expect(logger.info).toHaveBeenCalledWith(
      'Processing completed'
    );
  });
});

条件付きモック

環境や条件に応じて異なるモック実装を提供できます。

javascript// 環境に応じたモック
jest.mock('./config', () => {
  if (process.env.NODE_ENV === 'test') {
    return {
      apiUrl: 'http://localhost:3000',
      timeout: 1000,
    };
  }
  return jest.requireActual('./config');
});

jest.spyOn() での部分モック

jest.spyOn() は、既存のオブジェクトのメソッドをモック化しつつ、元の実装を保持する機能です。オブジェクトの一部だけをモック化したい場合に有用です。

オブジェクトメソッドのスパイ

javascript// Math オブジェクトのメソッドをスパイ
describe('Math.random のスパイ', () => {
  it('should spy on Math.random', () => {
    const mathSpy = jest
      .spyOn(Math, 'random')
      .mockReturnValue(0.5);

    const result = Math.random();
    expect(result).toBe(0.5);
    expect(mathSpy).toHaveBeenCalledTimes(1);

    // スパイを元に戻す
    mathSpy.mockRestore();
  });
});

クラスメソッドの部分モック

javascriptclass PaymentService {
  calculateFee(amount) {
    return amount * 0.03;
  }

  async processPayment(amount) {
    const fee = this.calculateFee(amount);
    const total = amount + fee;

    // 実際の決済処理(外部API呼び出し)
    return await this.callPaymentAPI(total);
  }

  async callPaymentAPI(amount) {
    // 実際のAPI呼び出し処理
    throw new Error('Real API call');
  }
}

describe('PaymentService 部分モック', () => {
  it('should mock only API call method', async () => {
    const paymentService = new PaymentService();

    // API呼び出しメソッドのみモック化
    const apiSpy = jest
      .spyOn(paymentService, 'callPaymentAPI')
      .mockResolvedValue({
        success: true,
        transactionId: '123',
      });

    const result = await paymentService.processPayment(100);

    // 手数料計算は実際の実装を使用
    expect(apiSpy).toHaveBeenCalledWith(103); // 100 + 3
    expect(result).toEqual({
      success: true,
      transactionId: '123',
    });

    apiSpy.mockRestore();
  });
});

グローバルオブジェクトのスパイ

javascriptdescribe('Date のスパイ', () => {
  it('should mock current date', () => {
    const mockDate = new Date('2023-01-01T00:00:00.000Z');
    const dateSpy = jest
      .spyOn(global, 'Date')
      .mockImplementation(() => mockDate);

    const timestamp = new Date();
    expect(timestamp).toBe(mockDate);

    dateSpy.mockRestore();
  });
});

タイマーモックと Fake Timers

時間に依存する処理をテストする際は、Jest の Fake Timers 機能を使用します。これにより、時間の経過を制御してテストできます。

基本的なタイマーモック

javascriptdescribe('Timer Mock の基本', () => {
  beforeEach(() => {
    jest.useFakeTimers();
  });

  afterEach(() => {
    jest.useRealTimers();
  });

  it('should execute setTimeout callback', () => {
    const callback = jest.fn();

    setTimeout(callback, 1000);

    // まだ実行されていない
    expect(callback).not.toHaveBeenCalled();

    // 時間を進める
    jest.advanceTimersByTime(1000);

    // コールバックが実行される
    expect(callback).toHaveBeenCalledTimes(1);
  });

  it('should handle multiple timers', () => {
    const callback1 = jest.fn();
    const callback2 = jest.fn();

    setTimeout(callback1, 100);
    setTimeout(callback2, 200);

    jest.advanceTimersByTime(150);
    expect(callback1).toHaveBeenCalled();
    expect(callback2).not.toHaveBeenCalled();

    jest.advanceTimersByTime(100);
    expect(callback2).toHaveBeenCalled();
  });
});

setInterval の制御

javascriptdescribe('setInterval のテスト', () => {
  beforeEach(() => {
    jest.useFakeTimers();
  });

  afterEach(() => {
    jest.useRealTimers();
  });

  it('should execute interval callback multiple times', () => {
    const callback = jest.fn();

    setInterval(callback, 1000);

    // 3秒経過をシミュレート
    jest.advanceTimersByTime(3000);

    expect(callback).toHaveBeenCalledTimes(3);
  });
});

実用的なタイマーテスト例

javascriptclass PollingService {
  constructor(apiClient, interval = 5000) {
    this.apiClient = apiClient;
    this.interval = interval;
    this.polling = false;
    this.timerId = null;
  }

  startPolling() {
    if (this.polling) return;

    this.polling = true;
    this.timerId = setInterval(() => {
      this.fetchData();
    }, this.interval);
  }

  stopPolling() {
    if (this.timerId) {
      clearInterval(this.timerId);
      this.timerId = null;
    }
    this.polling = false;
  }

  async fetchData() {
    try {
      await this.apiClient.getData();
    } catch (error) {
      console.error('Polling error:', error);
    }
  }
}

describe('PollingService', () => {
  let pollingService;
  let mockApiClient;

  beforeEach(() => {
    jest.useFakeTimers();
    mockApiClient = {
      getData: jest
        .fn()
        .mockResolvedValue({ data: 'test' }),
    };
    pollingService = new PollingService(
      mockApiClient,
      1000
    );
  });

  afterEach(() => {
    jest.useRealTimers();
  });

  it('should poll data at specified intervals', () => {
    pollingService.startPolling();

    expect(mockApiClient.getData).not.toHaveBeenCalled();

    // 1秒経過
    jest.advanceTimersByTime(1000);
    expect(mockApiClient.getData).toHaveBeenCalledTimes(1);

    // さらに2秒経過
    jest.advanceTimersByTime(2000);
    expect(mockApiClient.getData).toHaveBeenCalledTimes(3);

    pollingService.stopPolling();
  });
});

自動モックと手動モック

Jest では、モジュールのモック化を自動化する機能と、詳細な制御のための手動モック機能があります。

自動モック

javascript// 全ての外部モジュールを自動モック化
jest.mock('./external-service');

import { ExternalService } from './external-service';

// ExternalService のすべてのメソッドが自動的にモック関数になる
describe('自動モック', () => {
  it('should auto-mock all methods', () => {
    const service = new ExternalService();

    // メソッドはモック関数として動作
    service.getData.mockReturnValue('mocked data');

    expect(service.getData()).toBe('mocked data');
    expect(service.getData).toHaveBeenCalled();
  });
});

手動モック(__mocks__ ディレクトリ)

javascript// __mocks__/fs.js - Node.js の fs モジュールのモック
export const promises = {
  readFile: jest.fn(),
  writeFile: jest.fn(),
  mkdir: jest.fn(),
};

export const createReadStream = jest.fn();
export const createWriteStream = jest.fn();
javascript// テストファイル
import fs from 'fs';

jest.mock('fs'); // __mocks__/fs.js が使用される

describe('ファイル操作のテスト', () => {
  it('should read file content', async () => {
    fs.promises.readFile.mockResolvedValue('file content');

    const content = await fs.promises.readFile('test.txt');
    expect(content).toBe('file content');
  });
});

条件付き手動モック

javascript// __mocks__/config.js
module.exports = {
  development: {
    apiUrl: 'http://localhost:3000',
    debug: true,
  },
  test: {
    apiUrl: 'http://test-api.example.com',
    debug: false,
  },
  production: {
    apiUrl: 'https://api.example.com',
    debug: false,
  },
};

ファクトリー関数によるモック

javascript// より柔軟なモック実装
jest.mock('./database', () => {
  return {
    connect: jest.fn().mockResolvedValue(true),
    disconnect: jest.fn().mockResolvedValue(true),
    query: jest.fn(),
    transaction: jest.fn((callback) => {
      // トランザクションの擬似実装
      return callback({
        query: jest.fn(),
        commit: jest.fn(),
        rollback: jest.fn(),
      });
    }),
  };
});
#モック種類適用場面制御レベル
1jest.fn()単一関数のモック高い
2jest.mock()モジュール全体のモック中程度
3jest.spyOn()部分的なモック高い
4Fake Timers時間依存処理中程度
5自動モック迅速なモック作成低い
6__mocks__ ディレクトリ再利用可能なモック中程度

まとめ

Jest のモック機能は、効果的なテスト作成において不可欠な要素です。この記事で解説した段階的なアプローチにより、基本的な関数モックから高度なモックパターンまで体系的に習得できるでしょう。

段階的習得の重要性

モック機能は複雑で多様ですが、段階的に学習することで確実にマスターできます。まずは jest.fn() による基本的なモック関数から始め、徐々に jest.mock()jest.spyOn()、タイマーモックへと発展させていくことが重要です。

実践的な活用指針

実際のプロジェクトでは、テスト対象の特性に応じて適切なモック手法を選択することが求められます。外部依存関係の種類、テストの目的、保守性の要件などを総合的に考慮して、最適なモック戦略を策定しましょう。

継続的な改善

モック機能の習得は一度で完了するものではありません。新しいライブラリやフレームワークの導入、アプリケーションの複雑化に伴い、モック手法も進化させる必要があります。この記事で学んだ基礎知識を土台に、継続的にスキルを向上させていってください。

適切なモック機能の活用により、テストの実行速度、安定性、保守性が大幅に向上します。開発効率の向上と品質保証の両立を実現するため、Jest のモック機能を積極的に活用していきましょう。

関連リンク