はじめに (対象読者・この記事でわかること)
この記事は、JavaScriptやTypeScriptを使って開発しており、Jestで単体テストを記述している開発者を対象としています。特に、テスト対象のコードがnpmからインストールした外部パッケージに依存しており、そのパッケージのモック化に悩んでいる方、jest.mockを使っても期待通りに動作しないといった問題に直面している方に役立つでしょう。
この記事を読むことで、Jestにおけるモジュールモックの基本的な仕組みから、npmパッケージを効果的にモック化するための具体的な手法までを深く理解できます。よくある「モックが効かない」といった問題の原因と、その解決策をコード例と共に学ぶことで、あなたのテストコードの品質向上と開発効率アップに貢献できるはずです。
前提知識
この記事を読み進める上で、以下の知識があるとスムーズです。
- JavaScript (ES6以降) の基本的な知識
- Jestによる単体テストの基本的な書き方 (describe, it, expectなど)
- npm (Node Package Manager) の基本的な使い方
npmパッケージのモック化、なぜ難しいのか?
単体テストにおいて、テスト対象のコードが外部依存を持つ場合、その依存をモック(偽物)に置き換えることは非常に重要です。これにより、テストが外部要因に左右されず、テスト対象の機能のみを独立して検証できるようになります。しかし、npmでインストールしたパッケージのモック化は、時に開発者を悩ませます。
Jestには強力なモック機能を提供するjest.mock()がありますが、これを単にjest.mock('package-name')と記述するだけでは、期待通りにモックが適用されないケースが少なくありません。その背景には、JavaScriptのモジュールシステム(CommonJSとES Modules)の複雑さや、Jestがモジュールを解決し、モックを適用するタイミングが深く関わっています。
特に、npmパッケージはCommonJS形式で公開されているものもあれば、ES Modules形式で公開されているもの、あるいは両方をサポートしているものもあります。Jestはテスト実行時にこれらのモジュールをトランスパイル・解決しますが、この過程でモックが正しく差し込まれない、あるいはモックしたい関数が名前付きエクスポートなのか、デフォルトエクスポートなのかによってもアプローチが変わってきます。
このセクションでは、モック化の必要性と、npmパッケージのモック化が難しいと感じる一般的な理由について理解を深めていきましょう。
Jestでnpmパッケージを効果的にモック化する具体的な手法
ここからは、実際にnpmパッケージをモック化する具体的な手順と、よくある課題に対する解決策を詳しく見ていきます。ここでは、HTTPクライアントとして広く使われているaxiosを例に、モック化の方法を解説します。
ステップ1: 問題の確認と基本的なアプローチ
まず、モック化が必要な典型的なシナリオを見てみましょう。以下のようなUserServiceがあり、axiosを使ってユーザー情報を取得する関数があるとします。
Javascript// src/services/UserService.js import axios from 'axios'; export class UserService { static async getUser(userId) { try { const response = await axios.get(`https://api.example.com/users/${userId}`); return response.data; } catch (error) { console.error('Failed to fetch user:', error); throw error; } } static async createUser(userData) { try { const response = await axios.post('https://api.example.com/users', userData); return response.data; } catch (error) { console.error('Failed to create user:', error); throw error; } } }
このUserServiceの単体テストを記述する際、実際にAPIコールが行われると、テストの実行が遅くなったり、ネットワークの状態に依存したり、予期せぬ副作用(データベースへの書き込みなど)が発生する可能性があります。これを避けるため、axiosのモック化が必要です。
一般的なjest.mockの書き方は以下のようになります。
Javascript// test/services/UserService.test.js import { UserService } from '../src/services/UserService'; import axios from 'axios'; // このimportはモック化されたaxiosを参照する // Jestがaxiosモジュールをモック化する jest.mock('axios'); describe('UserService', () => { beforeEach(() => { // 各テストの前にモックの状態をリセット jest.clearAllMocks(); }); it('should fetch user data successfully', async () => { // axios.getの戻り値をモック axios.get.mockResolvedValueOnce({ data: { id: 1, name: 'Test User' } }); const user = await UserService.getUser(1); expect(axios.get).toHaveBeenCalledTimes(1); expect(axios.get).toHaveBeenCalledWith('https://api.example.com/users/1'); expect(user).toEqual({ id: 1, name: 'Test User' }); }); it('should create user data successfully', async () => { axios.post.mockResolvedValueOnce({ data: { id: 2, name: 'New User' } }); const newUser = { name: 'New User' }; const createdUser = await UserService.createUser(newUser); expect(axios.post).toHaveBeenCalledTimes(1); expect(axios.post).toHaveBeenCalledWith('https://api.example.com/users', newUser); expect(createdUser).toEqual({ id: 2, name: 'New User' }); }); });
この例は、axiosのようなデフォルトエクスポートされたライブラリで、かつ特定のメソッド(get, postなど)が使われる場合にうまく機能します。jest.mock('axios');と記述すると、axiosモジュール全体がモックされ、そのプロパティ(get, postなど)はJestのモック関数として扱われるようになります。
ステップ2: モジュールモックの仕組みとファクトリー関数
しかし、すべてのnpmパッケージがaxiosのようにうまくモックできるわけではありません。特に、以下のようなケースで問題が発生することがあります。
- 名前付きエクスポート(Named Exports)を持つパッケージ:
import { specificFunc } from 'some-utility-package';のように、特定の関数だけをインポートしている場合。 - モックに具体的な実装を渡したい場合: 単にモックするだけでなく、モックされた関数が特定の振る舞いをするようにしたい場合。
このような場合に役立つのが、jest.mock()の第二引数に渡す「ファクトリー関数」です。ファクトリー関数は、モックされたモジュールが実際にどのようなオブジェクトを返すかを定義します。
例1: 名前付きエクスポートを持つパッケージのモック
例えば、あるライブラリがcapitalizeという関数を名前付きエクスポートしているとします。
Javascript// src/utils/textFormatter.js import { capitalize } from 'lodash'; // lodashからcapitalizeをインポート export const formatText = (text) => { return capitalize(text); };
このcapitalizeをモック化したい場合、ファクトリー関数を使います。
Javascript// test/utils/textFormatter.test.js import { formatText } from '../src/utils/textFormatter'; import * as lodash from 'lodash'; // lodash全体をインポート // lodashモジュールをモック化。 // capitalize関数だけをモックし、他は実際のlodashの関数を使う jest.mock('lodash', () => ({ ...jest.requireActual('lodash'), // lodashの他の実際の関数を保持 capitalize: jest.fn((str) => str.toUpperCase()), // capitalizeだけ大文字にするモック })); describe('formatText', () => { it('should format text by capitalizing it', () => { // ここではjest.mockで設定したcapitalizeモックが使われる const result = formatText('hello world'); expect(result).toBe('HELLO WORLD'); expect(lodash.capitalize).toHaveBeenCalledWith('hello world'); }); });
jest.requireActual('lodash')を使うことで、モック化したい関数以外はオリジナルの実装を使用することができます。これにより、必要な部分だけをモックし、他の機能への影響を最小限に抑えられます。
例2: クラスのモック化
モックしたい対象がクラスである場合も、ファクトリー関数が有効です。
Javascript// src/clients/MyApiClient.js import { ThirdPartyApiClient } from 'third-party-api-client'; // 外部のクラス export class MyService { constructor() { this.apiClient = new ThirdPartyApiClient('some_api_key'); } async fetchData() { return this.apiClient.get('/data'); } }
ThirdPartyApiClientクラスをモック化したい場合、以下のようにします。
Javascript// test/clients/MyApiClient.test.js import { MyService } from '../src/clients/MyApiClient'; import { ThirdPartyApiClient } from 'third-party-api-client'; // クラス全体をモック化し、コンストラクタが呼ばれた時にモックメソッドを持つインスタンスを返す jest.mock('third-party-api-client', () => { return { ThirdPartyApiClient: jest.fn().mockImplementation(() => { return { get: jest.fn(), post: jest.fn(), }; }), }; }); describe('MyService', () => { let mockApiClientInstance; beforeEach(() => { // jest.mockによりThirdPartyApiClientがモックされていることを確認 mockApiClientInstance = ThirdPartyApiClient.mock.instances[0]; jest.clearAllMocks(); }); it('should fetch data using the api client', async () => { const service = new MyService(); // モックインスタンスのgetメソッドの戻り値を設定 mockApiClientInstance.get.mockResolvedValueOnce({ status: 200, data: 'mocked data' }); const result = await service.fetchData(); expect(ThirdPartyApiClient).toHaveBeenCalledTimes(1); expect(ThirdPartyApiClient).toHaveBeenCalledWith('some_api_key'); expect(mockApiClientInstance.get).toHaveBeenCalledWith('/data'); expect(result).toEqual({ status: 200, data: 'mocked data' }); }); });
ここでは、ThirdPartyApiClientクラスのコンストラクタ自体をモックし、そのコンストラクタが呼ばれたときに、モックされたgetやpostメソッドを持つインスタンスを返すように設定しています。jest.fn().mockImplementation(() => { ... })がポイントです。
ハマった点やエラー解決
Jestでモックがうまく効かない場合、いくつかの典型的な原因があります。
-
jest.mock()の記述位置:jest.mock()は、テストファイル内でimport文よりも先に評価されます。つまり、import文の前にjest.mockを記述しないと、元のモジュールが先に読み込まれてしまい、モックが適用されません。通常、ファイルのトップレベルに記述します。 -
ES Modules (ESM) と CommonJS (CJS) の混在: Node.jsのモジュールシステムは、CJS (
require/module.exports) と ESM (import/export) の両方が存在します。Jestは通常BabelやTypeScriptでESMをCJSにトランスパイルしてテストを実行しますが、特定のパッケージがESMとしてのみ提供されている場合や、トランスパイルの設定が正しくない場合にモックが期待通りに動作しないことがあります。 よくあるエラーはTypeError: (0 , _package_name.someFunc) is not a functionのようなものです。これは、モックが正しく適用されず、本来は関数であるべきものがundefinedとして扱われている場合に発生します。 -
モックのキャッシュ: Jestはモジュールをキャッシュします。一度モックが適用されると、その後のテストでも同じモックが使われます。もし、テストごとに異なるモックの振る舞いをさせたい場合は、
jest.clearAllMocks(),jest.resetModules(),jest.restoreAllMocks()などをbeforeEachで適切に使う必要があります。jest.clearAllMocks(): 全てのモック関数の呼び出し回数や戻り値をリセットします。jest.resetModules(): 全てのモジュールをキャッシュから削除し、次回のrequire/import時に再度読み込みます。これにより、jest.mockが再評価されますが、パフォーマンスに影響を与える可能性があります。jest.restoreAllMocks():jest.spyOnで作成されたスパイを元の実装に戻します。
解決策
上記の問題を解決するための具体的なアプローチは以下の通りです。
-
jest.mock()の正しい配置: 常にテストファイルの最上部に記述することを徹底しましょう。 -
ESM/CJSの対応:
jest.mock()のファクトリー関数内で、エクスポート形式を意識したオブジェクトを返します。- デフォルトエクスポートのモック:
jest.mock('package', () => 'mocked-value');またはjest.mock('package', () => ({ __esModule: true, default: 'mocked-value' })); - 名前付きエクスポートのモック:
jest.mock('package', () => ({ namedExport: jest.fn() }));
特に、ESMパッケージで
TypeErrorが発生する場合は、__esModule: true, default: ...のように明示的にESMの構造を再現するファクトリー関数を試してみてください。 - デフォルトエクスポートのモック:
-
詳細なデバッグ: モックが適用されているか不明な場合は、
console.logでモックオブジェクトの中身を確認したり、jest.mockのファクトリー関数内でログを出力して、実際にモックが生成されているか確認するのも有効です。 -
jest.spyOnの活用: もし、モジュール全体をモックするのではなく、特定のメソッドだけを一時的にモックしたい場合は、jest.spyOnが強力です。これはオリジナルの実装を残しつつ、特定のメソッドの呼び出しを監視・制御できます。```javascript // UserService.js は変更なし // test/services/UserService.test.js import { UserService } from '../src/services/UserService'; import axios from 'axios';
describe('UserService with spyOn', () => { let axiosGetSpy; let axiosPostSpy;
beforeEach(() => { // 各テストの前にスパイを作成し、実装をモック axiosGetSpy = jest.spyOn(axios, 'get'); axiosPostSpy = jest.spyOn(axios, 'post'); });
afterEach(() => { // 各テストの後でスパイを復元(重要!) axiosGetSpy.mockRestore(); axiosPostSpy.mockRestore(); });
it('should fetch user data successfully', async () => { axiosGetSpy.mockResolvedValueOnce({ data: { id: 1, name: 'Test User (spyOn)' } });
const user = await UserService.getUser(1); expect(axiosGetSpy).toHaveBeenCalledTimes(1); expect(axiosGetSpy).toHaveBeenCalledWith('https://api.example.com/users/1'); expect(user).toEqual({ id: 1, name: 'Test User (spyOn)' });});
it('should create user data successfully', async () => { axiosPostSpy.mockResolvedValueOnce({ data: { id: 2, name: 'New User (spyOn)' } });
const newUser = { name: 'New User (spyOn)' }; const createdUser = await UserService.createUser(newUser); expect(axiosPostSpy).toHaveBeenCalledTimes(1); expect(axiosPostSpy).toHaveBeenCalledWith('https://api.example.com/users', newUser); expect(createdUser).toEqual({ id: 2, name: 'New User (spyOn)' });}); });
``jest.spyOnは、オリジナルの実装を呼び出すか、モック実装を呼び出すかを柔軟に切り替えられる点が優れています。mockRestore()をafterEach`で呼び出すことで、他のテストへの影響を防ぎます。
まとめ
本記事では、Jestでnpmパッケージをモック化する際の課題と、その効果的な解決策について解説しました。
jest.mock()の基本とファクトリー関数の活用: モジュールのエクスポート形式(デフォルトエクスポート、名前付きエクスポート、クラス)に応じて、jest.mock()の第二引数であるファクトリー関数を適切に定義することが重要です。jest.requireActual()を組み合わせることで、必要な部分だけをモックし、その他の部分は元の実装を利用できます。- よくあるハマりどころと対処法:
jest.mock()の評価順序、ESM/CJSの差異、モジュールキャッシュの問題など、モックが効かない原因を特定し、それぞれの解決策を学びました。 jest.spyOnの活用: モジュール全体をモックするのではなく、特定のメソッドだけを一時的に制御したい場合にjest.spyOnが非常に有効であることを理解しました。
この記事を通して、Jestを使ったテストコードでnpmパッケージの依存関係を効果的に管理し、より堅牢で信頼性の高い単体テストを書くための知識を得られたことでしょう。テストの精度が向上することで、開発サイクルのスピードアップやバグの早期発見にも繋がります。
今後は、テストカバレッジの向上や、より複雑な非同期処理のテスト手法についても記事にする予定です。
参考資料
- Jest 公式ドキュメント - Mock Functions
- Jest 公式ドキュメント - ES Module Interop
- Jest 公式ドキュメント - jest.mock(moduleName, factory, options)