ES6 クラスのモック
Jest は、テストしたいファイルにインポートした ES6 クラスをモックすることもできます。
ES6 クラスというのは、いくつかの糖衣構文を加えたコンストラクタ関数です。 したがって、ES6 クラスのモックは、何らかの関数であるか、もう一つの ES6 クラス (繰り返しますが、これは別の関数です) になります。 そのため、モック関数を使用することでモックを作成できます。
ES6 クラスの例
具体例として、音楽ファイルを再生するクラス SoundPlayer
とそのクラスを使用する消費者クラス SoundPlayerConsumer
について考えてみましょう。 SoundPlayerConsumer
のテスト内で、SoundPlayer
クラスをモックするには、次のようなコードを書きます。
export default class SoundPlayer {
constructor() {
this.foo = 'bar';
}
playSoundFile(fileName) {
console.log('Playing sound file ' + fileName);
}
}
import SoundPlayer from './sound-player';
export default class SoundPlayerConsumer {
constructor() {
this.soundPlayer = new SoundPlayer();
}
playSomethingCool() {
const coolSoundFileName = 'song.mp3';
this.soundPlayer.playSoundFile(coolSoundFileName);
}
}
ES6 クラスのモックを作る4つの方法
自動モック
jest.mock('./sound-player')
を呼ぶと、便利な "自動モック" を返してくれます。 これは、クラスのコンストラクタおよびすべてのメソッドの呼び出しをスパイするのに使用できます。 この関数は ES6 クラスをモックコンストラクタに置き換え、すべてのメソッドを、常に undefined
を返すモック関数に置き換えます。 メソッドの呼び出しは theAutomaticMock.mock.instances[index].methodName.mock.calls
に保存されます。
If you use arrow functions in your classes, they will not be part of the mock. なぜなら、アロー関数はオブジェクトのプロトタイプには現れず、単に関数への参照を保持しているプロパティに過ぎないためです。
クラスの実装を置き換える必要がない場合は、このメソッドを使用するのが最も簡単な選択肢です。 例:
import SoundPlayer from './sound-player';
import SoundPlayerConsumer from './sound-player-consumer';
jest.mock('./sound-player'); // SoundPlayer is now a mock constructor
beforeEach(() => {
// Clear all instances and calls to constructor and all methods:
SoundPlayer.mockClear();
});
it('We can check if the consumer called the class constructor', () => {
const soundPlayerConsumer = new SoundPlayerConsumer();
expect(SoundPlayer).toHaveBeenCalledTimes(1);
});
it('We can check if the consumer called a method on the class instance', () => {
// Show that mockClear() is working:
expect(SoundPlayer).not.toHaveBeenCalled();
const soundPlayerConsumer = new SoundPlayerConsumer();
// Constructor should have been called again:
expect(SoundPlayer).toHaveBeenCalledTimes(1);
const coolSoundFileName = 'song.mp3';
soundPlayerConsumer.playSomethingCool();
// mock.instances is available with automatic mocks:
const mockSoundPlayerInstance = SoundPlayer.mock.instances[0];
const mockPlaySoundFile = mockSoundPlayerInstance.playSoundFile;
expect(mockPlaySoundFile.mock.calls[0][0]).toBe(coolSoundFileName);
// Equivalent to above check:
expect(mockPlaySoundFile).toHaveBeenCalledWith(coolSoundFileName);
expect(mockPlaySoundFile).toHaveBeenCalledTimes(1);
});
マニュアルモック
マニュアルモックを作るには、モックの実装を __mocks__
ディレクトリに保存します。 これにより、特定の実装をテストファイル全体で使用することができるようになります。
// Import this named export into your test file:
export const mockPlaySoundFile = jest.fn();
const mock = jest.fn().mockImplementation(() => {
return {playSoundFile: mockPlaySoundFile};
});
export default mock;
すべてのインスタンスで共有される、モックとモックメソッドをインポートします。
import SoundPlayer, {mockPlaySoundFile} from './sound-player';
import SoundPlayerConsumer from './sound-player-consumer';
jest.mock('./sound-player'); // SoundPlayer is now a mock constructor
beforeEach(() => {
// Clear all instances and calls to constructor and all methods:
SoundPlayer.mockClear();
mockPlaySoundFile.mockClear();
});
it('We can check if the consumer called the class constructor', () => {
const soundPlayerConsumer = new SoundPlayerConsumer();
expect(SoundPlayer).toHaveBeenCalledTimes(1);
});
it('We can check if the consumer called a method on the class instance', () => {
const soundPlayerConsumer = new SoundPlayerConsumer();
const coolSoundFileName = 'song.mp3';
soundPlayerConsumer.playSomethingCool();
expect(mockPlaySoundFile).toHaveBeenCalledWith(coolSoundFileName);
});
jest.mock()
をモジュールファクトリ引数で呼ぶ
jest.mock(path, moduleFactory)
はモジュールファクトリ引数を取ります。 モジュールファクトリとは、モックを返す関数のことです。
コンストラクタ関数をモックするためには、モジュールファクトリはコンストラクタ関数を返さなければなりません。 言い換えると、モジュールファクトリは関数を返す関数、つまり高階関数 (high-order function; HOF) でなければなりません。
import SoundPlayer from './sound-player';
const mockPlaySoundFile = jest.fn();
jest.mock('./sound-player', () => {
return jest.fn().mockImplementation(() => {
return {playSoundFile: mockPlaySoundFile};
});
});
Since calls to jest.mock()
are hoisted to the top of the file, Jest prevents access to out-of-scope variables. By default, you cannot first define a variable and then use it in the factory. Jest will disable this check for variables that start with the word mock
. However, it is still up to you to guarantee that they will be initialized on time. Be aware of Temporal Dead Zone.
For example, the following will throw an out-of-scope error due to the use of fake
instead of mock
in the variable declaration.
// 注意: これは失敗します
import SoundPlayer from './sound-player';
const fakePlaySoundFile = jest.fn();
jest.mock('./sound-player', () => {
return jest.fn().mockImplementation(() => {
return {playSoundFile: fakePlaySoundFile};
});
});
The following will throw a ReferenceError
despite using mock
in the variable declaration, as the mockSoundPlayer
is not wrapped in an arrow function and thus accessed before initialization after hoisting.
import SoundPlayer from './sound-player';
const mockSoundPlayer = jest.fn().mockImplementation(() => {
return {playSoundFile: mockPlaySoundFile};
});
// results in a ReferenceError
jest.mock('./sound-player', () => {
return mockSoundPlayer;
});
mockImplementation()
または mockImplementationOnce()
を使用したモックを置き換える
以上すべてのモックで実装を変更するには、1つのテストまたはすべてのテストに対して、既存のモックの中で mockImplementation()
を呼び出します。
jest.mock の呼び出しはコードのトップに引き上げられます。 たとえば beforeAll()
の中などで後からモックを指定するには、ファクトリ引数で指定するのではなく、mockImplementation()
(または mockImplementationOnce()
) を既存のモックの中で呼び出します。 これにより、テスト間で必要に応じてモックを変更することができるようになります。
import SoundPlayer from './sound-player';
import SoundPlayerConsumer from './sound-player-consumer';
jest.mock('./sound-player');
describe('When SoundPlayer throws an error', () => {
beforeAll(() => {
SoundPlayer.mockImplementation(() => {
return {
playSoundFile: () => {
throw new Error('Test error');
},
};
});
});
it('Should throw an error when calling playSomethingCool', () => {
const soundPlayerConsumer = new SoundPlayerConsumer();
expect(() => soundPlayerConsumer.playSomethingCool()).toThrow();
});
});
踏み込んだ話: モックコンストラクタ関数を理解する
コンストラクタ関数を構築する時、jest.fn().mockImplementation()
を使用したモックは実際よりも複雑な見た目になります。 このセクションでは、モックの動作を説明しながら、シンプルなカスタムモックを作っていきます。
マニュアルモックは1つの ES6 クラスである
__mocks__
ディレクトリ内にモッククラスと同じファイル名を使用した ES6 クラスを定義すると、モックとして機能します。 このクラスは実際のクラスの代わりに使われます。 これにより、クラスにテスト実装を注入できますが、関数呼び出しをスパイする手段は提供されません。
具体例として、モックは次のようになります。
export default class SoundPlayer {
constructor() {
console.log('Mock SoundPlayer: constructor was called');
}
playSoundFile() {
console.log('Mock SoundPlayer: playSoundFile was called');
}
}
モジュールファクトリパラメータを使用してモックする
jest.mock(path, moduleFactory)
に渡されたモジュールファクトリ関数は、function* を返す高階関数にすることもできます。 こうすることで、モックに対して new
を呼ぶことができます。 繰り返しますが、こうすることで、テストに異なる動作を挿入することができますが、関数の呼び出しをスパイすることはできません。
* モジュールファクトリ関数は関数を返さなければならない
コンストラクタ関数をモックするためには、モジュールファクトリはコンストラクタ関数を返さなければなりません。 言い換えると、モジュールファクトリは関数を返す関数、つまり高階関数 (high-order function; HOF) でなければなりません。
jest.mock('./sound-player', () => {
return function () {
return {playSoundFile: () => {}};
};
});
The mock can't be an arrow function because calling new
on an arrow function is not allowed in JavaScript. そのため、以下のような関数は動作しません。
jest.mock('./sound-player', () => {
return () => {
// Does not work; arrow functions can't be called with new
return {playSoundFile: () => {}};
};
});
このように書くと、babel-preset-env などで ES5 にトランスパイルしない限り、TypeError: _soundPlayer2.default is not a constructor という例外が発生します。 (ES5 にはアロー関数やクラスが存在しないため、両方とも単純な関数に変換されるためです。
Mocking a specific method of a class
Lets say that you want to mock or spy on the method playSoundFile
within the class SoundPlayer
. A simple example:
// your jest test file below
import SoundPlayer from './sound-player';
import SoundPlayerConsumer from './sound-player-consumer';
const playSoundFileMock = jest
.spyOn(SoundPlayer.prototype, 'playSoundFile')
.mockImplementation(() => {
console.log('mocked function');
}); // comment this line if just want to "spy"
it('player consumer plays music', () => {
const player = new SoundPlayerConsumer();
player.playSomethingCool();
expect(playSoundFileMock).toHaveBeenCalled();
});
Static, getter and setter methods
Lets imagine our class SoundPlayer
has a getter method foo
and a static method brand
export default class SoundPlayer {
constructor() {
this.foo = 'bar';
}
playSoundFile(fileName) {
console.log('Playing sound file ' + fileName);
}
get foo() {
return 'bar';
}
static brand() {
return 'player-brand';
}
}
You can mock/spy on them easily, here is an example:
// your jest test file below
import SoundPlayer from './sound-player';
const staticMethodMock = jest
.spyOn(SoundPlayer, 'brand')
.mockImplementation(() => 'some-mocked-brand');
const getterMethodMock = jest
.spyOn(SoundPlayer.prototype, 'foo', 'get')
.mockImplementation(() => 'some-mocked-result');
it('custom methods are called', () => {
const player = new SoundPlayer();
const foo = player.foo;
const brand = SoundPlayer.brand();
expect(staticMethodMock).toHaveBeenCalled();
expect(getterMethodMock).toHaveBeenCalled();
});
使用状況をトラックし続ける (モックをスパイする)
テストの実装を注入するのは便利ですが、おそらく、クラスのコンストラクタやメソッドが正しい引数で呼ばれたかどうかも確認したいと考えるでしょう。
コンストラクタをスパイする
コンストラクタの呼び出しを追跡するには、高階関数が返す関数を Jest のモック関数に置換します。 モック関数は jest.fn()
で作ることができ、関数の実装は mockImplementation()
で指定します。
import SoundPlayer from './sound-player';
jest.mock('./sound-player', () => {
// Works and lets you check for constructor calls:
return jest.fn().mockImplementation(() => {
return {playSoundFile: () => {}};
});
});
This will let us inspect usage of our mocked class, using SoundPlayer.mock.calls
: expect(SoundPlayer).toHaveBeenCalled();
or near-equivalent: expect(SoundPlayer.mock.calls.length).toBeGreaterThan(0);
Mocking non-default class exports
クラスがモジュールからのデフォルトのエクスポートではない場合、クラスのエクスポート名と同じキーを持つオブジェクトを返す必要があります。
import {SoundPlayer} from './sound-player';
jest.mock('./sound-player', () => {
// 機能するのでコンストラクターの呼び出しが確認できます:
return {
SoundPlayer: jest.fn().mockImplementation(() => {
return {playSoundFile: () => {}};
}),
};
});
クラスのメソッドをスパイする
モッククラスは、任意のメンバー関数 (コード例では playSoundFile
) を提供する必要があります。 もし必要な関数がなければ、存在しない関数を呼び出した時にエラーが起こります。 しかし、おそらく期待通りの引数で呼び出されたことを確認するために、これらのメソッドの呼び出しに対してもスパイしたいと考えるでしょう。
テスト中は、モックコンストラクタ関数が呼ばれるたびに、新しいオブジェクトが作られます。 これらすべてのオブジェクトのメソッドの呼び出しをスパイするために、playSoundFile
とその他のモック関数を作成し、テスト中に利用できるようにその同一のモック関数への参照を保存しておきます。
import SoundPlayer from './sound-player';
const mockPlaySoundFile = jest.fn();
jest.mock('./sound-player', () => {
return jest.fn().mockImplementation(() => {
return {playSoundFile: mockPlaySoundFile};
// Now we can track calls to playSoundFile
});
});
このコードと同等のマニュアルモックは、次のように書けるでしょう。
// Import this named export into your test file
export const mockPlaySoundFile = jest.fn();
const mock = jest.fn().mockImplementation(() => {
return {playSoundFile: mockPlaySoundFile};
});
export default mock;
使用例は、モジュールファクトリ関数と同様ですが、jest.mock()
の第2引数を省略することができる点と、モックしたメソッドがテストファイル内にはないので、それをインポートする必要がある点が異なります。 __mocks__
を含まない、オリジナルのモジュールパスを使用してください。
テスト間でのクリーンアップ
モックコンストラクタ関数とメソッドの呼び出しの記録をクリアするには、mockClear()
を beforeEach()
関数の中で呼びます。
beforeEach(() => {
SoundPlayer.mockClear();
mockPlaySoundFile.mockClear();
});
完全に動作するコード例
以下に完全に動作するテストファイルの例を挙げます。 このコードでは、jest.mock
に対してモジュールファクトリ引数を与えています。
import SoundPlayer from './sound-player';
import SoundPlayerConsumer from './sound-player-consumer';
const mockPlaySoundFile = jest.fn();
jest.mock('./sound-player', () => {
return jest.fn().mockImplementation(() => {
return {playSoundFile: mockPlaySoundFile};
});
});
beforeEach(() => {
SoundPlayer.mockClear();
mockPlaySoundFile.mockClear();
});
it('The consumer should be able to call new() on SoundPlayer', () => {
const soundPlayerConsumer = new SoundPlayerConsumer();
// Ensure constructor created the object:
expect(soundPlayerConsumer).toBeTruthy();
});
it('We can check if the consumer called the class constructor', () => {
const soundPlayerConsumer = new SoundPlayerConsumer();
expect(SoundPlayer).toHaveBeenCalledTimes(1);
});
it('We can check if the consumer called a method on the class instance', () => {
const soundPlayerConsumer = new SoundPlayerConsumer();
const coolSoundFileName = 'song.mp3';
soundPlayerConsumer.playSomethingCool();
expect(mockPlaySoundFile.mock.calls[0][0]).toBe(coolSoundFileName);
});