1つのテストの中で、同じモジュールに対する`jest.mock`を、異なるファイルで複数回やるのはやめたほうがいい
いやそんなん当然だろという声もありそうな話だけど、jestの内部実装を追うのが面白かった&今後思い出したいときがありそうなのでメモしておくよ👶
結論
- 1つのテストの中で、同じモジュールに対する
jest.mockを、異なるファイルで複数回やるのはやめたほうがいい(あたりまえ体操)- テストで使用しているモジュール群の内部実装によって、複数あるそれらの
jest.mockのうち、どれが使われるかが変わってしまうから
- テストで使用しているモジュール群の内部実装によって、複数あるそれらの
- でもテスト用のhelperファイルみたいなのを作って、そこで
jest.mockしちゃっていて、テストが書いてある本体のファイルでもjest.mockしちゃうみたいなケースは起こりそうだと思うから気をつけたい
どういう理屈で何が起こるのかの簡単なメモ
jest.mockの動き方
jest.mock(moduleName, factory, options)は
moduleNameにヒットするimport/requireが行われたとき、factoryを実行してその結果を代わりに使わせるというもの。
まあこれはいいよね。
で、これは下記の3つの規則にしたがって動いている。これはドキュメントにexplicitに書かれているのか分からないんだけど、内部実装を追ったらこうなっていたという話(要は、まちがっていたらごめんねという話でもある)。
- hoist: あるファイルにjest.mockがあったら、それを一番最初に解決してから、import/requireの解決をしていく
- factory上書き: 同じmoduleNameに対して2回jest.mockすると、factoryは上書きされる(いわゆる「あとがち」)
- cache: factoryを実行したとき、その結果はキャッシュされて(内部的にはmoduleRegistryという場所)、以降のimport/requireモック時にはその結果が使われます(キャッシュはいわゆる「さきがち」(キャッシュなんだからそりゃそうなんだけど))
何がおこるか
例えば下記のようなケースを想定する。
import { a } from 'a.js'
import { helper } from 'helper.js'
jest.mock('mockedModule', factory1)
// ここからtestが書いてある
import { b } from 'b.js'
jest.mock('mockedModule',factory2)
// ここからhelperが書いてある
testfile.jsからテストを実行すると、こんな順に進みます(進み方1「hoist」より、同一ファイル内ではjest.mockが最初に来る)。
- testfile.jsのjest.mockを解決
- このあと
mockedModuleのimport/requireがあると、factory1が実行されてその結果を使うことになる
- このあと
- a.jsからのaのimportを解決……👶
- helper.jsからのhelperのimportを解決
- helper.jsのjest.mockを解決
- このあと
mockedModuleのimport/requireがあると、factory2が実行されてその結果を使うことになる(進み方2「factory上書き」より、factory1は捨てられる)
- このあと
- b.jsからbをimport
- helper.jsのjest.mockを解決
さて、👶以降のmockedModuleのimport/requireがどうmockされるかは、aがmockedModuleをimport/requireしているかで変わってしまいます。進み方3「cache」があるためです。
したがって、複数ファイルにまたがるjest.mockは、importしているモジュールの内部実装によってmockの中身が変わるおそれがあります🥺
チラ見した内部実装
jest.mockの中身はこれです。
const mock = (moduleName, mockFactory, options) => {
if (mockFactory !== undefined) {
return setMockFactory(moduleName, mockFactory, options);
}
const moduleID = this._resolver.getModuleID(
this._virtualMocks,
from,
moduleName,
{
conditions: this.cjsConditions
}
);
this._explicitShouldMock.set(moduleID, true);
return jestObject;
};
大事なのは2つです。
-
_explicitShouldMockにmoduleIDがセットされる -
mockFactoryがあるならsetMockFactoryされる
_explicitShouldMockは、import/require解決時に呼ばれるrequireModuleOrMockにて、解決対象のモジュールをモックすべきかの判定を担う_shouldMockCjsで参照されています。
requireModuleOrMock(from, moduleName) {
// this module is unmockable
if (moduleName === '@jest/globals') {
// @ts-expect-error: we don't care that it's not assignable to T
return this.getGlobalsForCjs(from);
}
try {
if (this._shouldMockCjs(from, moduleName, this._explicitShouldMock)) {
return this.requireMock(from, moduleName);
} else {
return this.requireModule(from, moduleName);
}
} catch (e) {
// 省略
_shouldMockCjs(from, moduleName, explicitShouldMock) {
const options = {
conditions: this.cjsConditions
};
const moduleID = this._resolver.getModuleID(
this._virtualMocks,
from,
moduleName,
options
);
const key = from + path().delimiter + moduleID;
if (explicitShouldMock.has(moduleID)) {
// guaranteed by `has` above
return explicitShouldMock.get(moduleID);
}
// 省略
requireModuleOrMockを見てもらうとわかるように、モックすべき場合はrequireMockが実行されます。ここがモックを作ったり、キャッシュしたりする場所です。
requireMock(from, moduleName) {
const moduleID = this._resolver.getModuleID(
this._virtualMocks,
from,
moduleName,
{
conditions: this.cjsConditions
}
);
if (this._isolatedMockRegistry?.has(moduleID)) {
return this._isolatedMockRegistry.get(moduleID);
} else if (this._mockRegistry.has(moduleID)) {
return this._mockRegistry.get(moduleID);
}
const mockRegistry = this._isolatedMockRegistry || this._mockRegistry;
if (this._mockFactories.has(moduleID)) {
// has check above makes this ok
const module = this._mockFactories.get(moduleID)();
mockRegistry.set(moduleID, module);
return module;
}
要するにmockRegistryがキャッシュです。下記のようになっていることが分かると思います👶
-
moduleIDがmockRegistryにヒットしない場合:mockFactoryをmoduleIDで引いてきて実行することでモックを作る。そのとき、mockRegistryに保管しておく -
moduleIDがmockRegistryにヒットする場合:mockRegistryにあるものを使う
おしまい
おしまいです!
Discussion