Jestの分かりにくい挙動たち

目次
  1. Jestはハマりやすかった
  2. 仕様まとめ
  3. モックを元実装に戻す方法
  4. モックの実行タイミング
  5. jest.doMock()の注意点
  6. jest.isolateModulesでモジュールへのモックの影響を閉じ込める
  7. 参考

Jestはハマりやすかった

数年Jestを書いていますが、Jestのモック周りで頭を抱えることが多く意図通りに動かすのに苦労しました。

何が難しくさせているのかというと主に以下の理由から来ているのかなと思っています。

  1. モックを元実装に戻す場合モック手段ごとに書き分ける必要がある
  2. モック手段ごとにモックが適用されるタイミングが異なる

今回はそれぞれの難しいと感じるポイントに関するJestの仕様をまとめてみました。(特によく使うモック手段をピックアップしています)

仕様まとめ

モックを元実装に戻す方法

モック手段 モック範囲 モックを元実装に戻す方法 備考
jest.spyOn() Function/Property mockFn.mockRestore(), jest.restoreAllMocks()
jest.mock() Module jest.unmock()/jest.dontMock() → jest.resetModules() → 再度import/require
jest.doMock() Module jest.unmock()/jest.dontMock() → jest.resetModules() → 再度import/require
Manual Mock Module jest.unmock()/jest.dontMock() → jest.resetModules() → 再度import/require __mocks__フォルダで定義したモック
Object.defineProperty() Property 予め元の値を保存した上で手動復元 グローバルオブジェクトのプロパティ変更時などに使用

特定のテストケース内でモックする場合、モックが不要になったらモック前の状態にリセットしてあげないと別テストケースにもモックが影響してしまい挙動が変わる可能性があります。 そういった他テストケースへの汚染を防止するため、これらのリセット処理を適切な場所(afterEach, テストケース末尾など)で実行してあげるのが重要です。

モックの実行タイミング

モック手段 適用タイミング 詳細
jest.spyOn() 実行時 テストコード実行時に既存オブジェクトのメソッド/プロパティをスパイ
jest.mock() テストを実行する環境により異なる(※) ファイルの最上部に自動移動され、importより前に実行
jest.doMock() 実行時 テストコード実行時。以降のrequire/importが影響を受ける
Manual Mock モジュール読み込み時 対象モジュールがrequire/importされる際に自動適用
Object.defineProperty() 実行時 テストコード実行時にプロパティを直接変更

※jest.mock()は以下のように環境により動作が変わります

環境 jest.mock()
CommonJS import 前に実行(hoisting される)
ESM 実行時(hoisting されない)

jest.doMock()の注意点

特にハマったのはjest.doMock()の挙動でした。

既にimportしているモジュールに対してjest.doMock()によるモックを参照させるには、jest.doMock()以降でrequire/importする必要があります。(jest.mock()やManual Mockならモジュール読み込み前にモックしてくれるのでこういうことは起きません。)

例えば以下のようなテストコードは意図通りにモックされません。

// テストケースの実行より前にこのimportが実施される
import { sendEmail } from './emailService';

describe('メール送信テスト', () => {
  test('メール送信が成功することをテスト', () => {
    // この時点でdoMockを呼んでも、既にemailServiceはimportされているので効果がない
    jest.doMock('./emailService', () => ({
      sendEmail: jest.fn(() => ({ success: true, messageId: 'mock-123' }))
    }));
    
    const result = sendEmail('user@example.com', 'テスト件名', 'テスト本文');
    expect(result.messageId).toBe('mock-123'); // ❌ ファイル冒頭のimportされた実装側が使用されてしまいテストが失敗する
  });
});

このようなケースでjest.doMock()によるモックを適用させたい場合、以下のようにimportを追記してあげると意図通りに動作します。

import { sendEmail } from './emailService';

describe('メール送信テスト', () => {
  test('メール送信が成功することをテスト', async () => {
    jest.doMock('./emailService', () => ({
      sendEmail: jest.fn(() => ({ success: true, messageId: 'mock-123' }))
    }));
    
    // dynamic importでモジュールを読み込む。doMock後のimportなのでモックが適用される。
    const { sendEmail } = await import('./emailService');

    const result = sendEmail('user@example.com', 'テスト件名', 'テスト本文');
    expect(result.messageId).toBe('mock-123'); // ✅ テストが成功する
  });
});

jest.isolateModulesでモジュールへのモックの影響を閉じ込める

jest.isolateModules(fn)を使うと通常のテストファイルでのモジュール読み込みとは別に独立したスコープでのモジュールキャッシュを持たせることができます。 jest.mock()等のモジュールのモックとテストケースをjest.isolateModules(fn)で囲ってあげることで、モジュールのモックの影響をjest.isolateModules(fn)の内部に閉じ込めることができ、他のテストケースへのモック汚染を防止できます。

具体的には下記のように記述します。(awaitする必要があるのでこの例ではjest.isolateModules(fn)のasync版であるisolateModulesAsync(fn)を使っています)

describe('メール送信テスト', () => {
  test('isolateModulesを使用するテスト', async () => {
    await jest.isolateModulesAsync(async () => {
      // このモックはisolateModulesの内部にのみ影響する
      jest.doMock('./emailService', () => ({
        sendEmail: jest.fn(() => ({ success: true, messageId: 'mock-123' }))
      }));
      
      const { sendEmail } = await import('./emailService');
      const result = sendEmail('user@example.com', 'テスト件名', 'テスト本文');
      expect(result.messageId).toBe('mock-123');
    });
  });

  test('実装側をそのまま使用するテスト', async () => {
    const { sendEmail } = await import('./emailService');

    // このテストケースは前のテストケースのモックの影響を受けない
    const result = sendEmail('user@example.com', 'テスト件名', 'テスト本文');
    expect(result.messageId).toBe('real-123');
  });
});

参考

https://jestjs.io/docs/jest-object#methods