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