🌞

1つのテストの中で、同じモジュールに対する`jest.mock`を、異なるファイルで複数回やるのはやめたほうがいい

2024/08/21に公開

いやそんなん当然だろという声もありそうな話だけど、jestの内部実装を追うのが面白かった&今後思い出したいときがありそうなのでメモしておくよ👶

結論

  • 1つのテストの中で、同じモジュールに対するjest.mockを、異なるファイルで複数回やるのはやめたほうがいい(あたりまえ体操)
    • テストで使用しているモジュール群の内部実装によって、複数あるそれらのjest.mockのうち、どれが使われるかが変わってしまうから
  • でもテスト用のhelperファイルみたいなのを作って、そこでjest.mockしちゃっていて、テストが書いてある本体のファイルでもjest.mockしちゃうみたいなケースは起こりそうだと思うから気をつけたい

どういう理屈で何が起こるのかの簡単なメモ

jest.mockの動き方

jest.mock(moduleName, factory, options)
moduleNameにヒットするimport/requireが行われたとき、factoryを実行してその結果を代わりに使わせるというもの。

まあこれはいいよね。

で、これは下記の3つの規則にしたがって動いている。これはドキュメントにexplicitに書かれているのか分からないんだけど、内部実装を追ったらこうなっていたという話(要は、まちがっていたらごめんねという話でもある)。

  1. hoist: あるファイルにjest.mockがあったら、それを一番最初に解決してから、import/requireの解決をしていく
  2. factory上書き: 同じmoduleNameに対して2回jest.mockすると、factoryは上書きされる(いわゆる「あとがち」)
  3. cache: factoryを実行したとき、その結果はキャッシュされて(内部的にはmoduleRegistryという場所)、以降のimport/requireモック時にはその結果が使われます(キャッシュはいわゆる「さきがち」(キャッシュなんだからそりゃそうなんだけど))

何がおこるか

例えば下記のようなケースを想定する。

testfile.js
import { a } from 'a.js'
import { helper } from 'helper.js'

jest.mock('mockedModule', factory1)

// ここからtestが書いてある
helper.js
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

さて、👶以降の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つです。

  1. _explicitShouldMockmoduleIDがセットされる
  2. 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がキャッシュです。下記のようになっていることが分かると思います👶

  • moduleIDmockRegistryにヒットしない場合: mockFactorymoduleIDで引いてきて実行することでモックを作る。そのとき、mockRegistryに保管しておく
  • moduleIDmockRegistryにヒットする場合: mockRegistryにあるものを使う

おしまい

おしまいです!

GitHubで編集を提案

Discussion