🐌

ESMのmock巻き上げ問題とVitestのvi.hoistedについて

2023/11/06に公開

はじめに

Vitestにはvi.hoistedというメソッドが用意されています。追加は2023年5月のv.0.31.0で、まだ日の浅いメソッドです。


リリースノート

このメソッドの意義を理解するために、ESM(ECMAScript モジュール)でのmockのhoist(巻き上げ)、VitestのESM対応、vi.hoistedの挙動について調べてみました。

環境

  • pnpm: 8.10.0
  • Vitest:0.34.6
  • MacBook Air (2020, M1)

想定読者

  • ESM(ECMAScript モジュール)、CommonJSの基本知識がある
    • 下記の記事など参考になる記事がたくさんありますのでご覧ください。

https://zenn.dev/yodaka/articles/596f441acf1cf3

  • Jest, Vitestの基本知識がある

    • インストール、mockの扱いなどについては記述を割きません。
  • ESMでテストを書いている/書こうとしている

    • Vitestを使う場合はほぼ当てはまるかと思います。

ESMでのstatic import / dynamic importについて

vi.hoistedが必要になる背景として、まずESMでのstatic import / dynamic importについて簡単に整理します。

static import

import { sample } from "../src/sample"

dynamic import

const { sample } = await import('../src/sample')

両者にはさまざまな違いがありますが、今回問題になるのは評価(evaluate)順です。MDN Web Docsのdynamic importの項には次のようにありました。

import宣言の構文(import something from "somewhere")は静的で、常にモジュールが 読み込まれた時点で評価される結果となります。

実際に動作検証している記事がわかりやすかったので挙げておきます。

https://qiita.com/tonkotsuboy_com/items/f672de5fdd402be6f065#これまでのimport

つまり、static importはモジュール読み込み時点(トップレベル)で評価され、dynamic importは評価されるタイミングを指定できる、という違いがあるようです。

なお、 Commonjsでのrequireの評価はdynamic importと同じです。(ただし、前者は同期、後者は非同期という違いあり。)

mockメソッドの巻き上げについて

モジュールをmockする際、mock宣言 → import(require)の順で評価される必要があります。
そのために、Jest、Vitestでは.mockをファイル上部に巻き上げます。

Don't forget that vi.mock call is hoisted to top of the file.
https://vitest.dev/guide/mocking.html

しかし前項で見たように、ESMのstatic importはトップレベルで評価されるため、mock宣言 → importの順が守られません。

これはJestのドキュメントですが、下記の記述があります。

jest.mock calls cannot be hoisted to the top of the module if you enabled ECMAScript modules support. The ESM module loader always evaluates the static imports before executing code.
(jest.mockの呼び出しは、ECMAScriptモジュールのサポートを有効にした場合、モジュールの最上部に巻き上げることはできません。ESMモジュールローダーは、コード実行前に常に静的インポートを評価されるためです。)
https://jestjs.io/ja/docs/manual-mocks#es-module-importを利用する

Since ESM evaluates static import statements before looking at the code, the hoisting of jest.mock calls that happens in CJS won't work for ESM. To mock modules in ESM, you need to use require or dynamic import() after jest.mock calls to load the mocked modules - the same applies to modules which load the mocked modules.
(ESMはコードを見る前に静的なimport文を評価するため、CJSで発生するjest.mockの呼び出しの巻き上げはESMでは機能しません。ESMでモジュールをモックするには、jest.mockの呼び出しの後にrequireまたは動的import()を使用してモックされたモジュールを読み込む必要があります - モックされたモジュールを読み込むモジュールにも同じことが適用されます。)
https://jestjs.io/ja/docs/ecmascript-modules#esm-におけるモジュールのモック

「jest.mockの呼び出しの後にrequireまたは動的import()を使用してモックされたモジュールを読み込む必要があります」とあるのは、前項で見たとおり、これらは評価順をコントロールできるためですね。

以下、Vitestに絞ってESMでのmockの評価順を調査してみました。[1]

Vitestでのstatic import

サンプルコードをいじりながら、Vitestの実際の挙動を見てみます。

mockする関数として下記を用意しました。

src/sample.ts
export function sample() {
  return "これはmockではありません"
}

サンプル1: static import without vi.hoisted

vi.hositedを使わないstatic importを使ったテストコードを実行してみます。

import { vi, test, expect } from "vitest"
import { sample } from "../src/sample"

const mockSample = vi.fn().mockImplementationOnce(() => "これはmockです")

vi.mock("../src/sample", () => {
  return {
    sample: mockSample
  }
})

test("sample", () => {
  expect(sample()).toBe("これはmockです")
})

これはエラーになります。

実行結果

ReferenceError: Cannot access 'mockSample' before initializationとある通り、mockSampleより先にvi.mockが巻き上げられ実行されているようです。

Vitestがどのように巻き上げをしているのか、該当しそうなVitestのソースコードにログを仕込んで確認してみました。

hoistを行うソースコード

https://github.com/vitest-dev/vitest/blob/4c957a27234cddda272d66c37300b8fa90370b6f/packages/vitest/src/node/hoistMocks.ts#L107-L193

すると、下記のように変換されていました。(コメントは筆者)

変換後のコード

const { vi, test, expect } = await import('vitest') // dynamic import
vi.mock("../src/sample", () => { // hoist
  return {
    sample: mockSample // undeclared variable
  };
})
const { sample } = await import('../src/sample') // dynamic import


const mockSample = vi.fn().mockImplementationOnce(() => "これはmockです");
;
test("sample", () => {
  expect(sample()).toBe("これはmockです");
});

ソースコードと見合わせながら確認すると、Vitestは次のようにコードを組み替えているようです。

  • static importをdynamic importに変換
  • トップレベルに以下の順で移動する
    1. vitestからのimport(const { vi, test, expect } = await import('vitest')
    2. vi.mock / vi.unmock
    3. そのほかのimport

したがって、mockSampleの宣言前にmockが行われるため、未定義の変数参照となります。

エラーにはなりましたが、Vitestはstatic importをdynamic importに変換し、mockを巻き上げることでmock宣言 → importの評価順を実現していることがわかりました。つまり、Vitestでは、importの変換とmock巻き上げでESM対応しているようです。

これはすなわち、Vitestのvi.mockドキュメントに、

The call to vi.mock is hoisted, so it doesn't matter where you call it. It will always be executed before all imports.

と書かれていることです。(厳密には"vitest"のimportよりは後...?)

vi.hoisted

しかし、mockの前にコードを実行したい場合に困ります。下記のようにmockの前に変数定義を差し込めないでしょうか。

* vitestからのimport(`const { vi, test, expect } = await import('vitest')`)
+ 変数定義など 
* vi.mock / vi.unmock
* そのほかのimport(dynamic importに変換される)

ここでようやくvi.hoistedの出番です。

https://vitest.dev/api/vi.html#vi-hoisted

実際にvi.hoistedを使ったサンプルで検証してみました。

サンプル2: With vi.hoisted

import { vi, test, expect } from "vitest"
import { sample } from "../src/sample"

// 変更
const { mockSample } = vi.hoisted(() => {
  return {
    mockSample: vi.fn().mockImplementationOnce(() => "これはmockです")
  }
})

vi.mock("../src/sample", () => {
  return {
    sample: mockSample
  }
})

test("sample", () => {
  expect(sample()).toBe("これはmockです")
})

先ほどと同様に変換後のコードをみると、以下のようになっていました。

変換後

const { vi, test, expect } = await import('vitest')
const { mockSample } = vi.hoisted(() => { // before vi.mock
  return {
    mockSample: vi.fn().mockImplementationOnce(() => "これはmockです")
  };
});
vi.mock("../src/sample", () => {
  return {
    sample: mockSample // already initialized
  };
})
const { sample } = await import('../src/sample')



;
test("sample", () => {
  expect(sample()).toBe("これはmockです");
});

vi.hoistedは引数のfactory関数の返り値を返しますので、今回はMock関数であるmockSamplevi.mockの前に定義できていることになります。テスト実行もpassします。

これができると、下記のようにmockSampleをtest内で操作しやすくなるので便利です。

import { vi, test, expect } from "vitest"
import { sample } from "../src/sample"

const { mockSample } = vi.hoisted(() => {
  return {
    mockSample: vi.fn().mockImplementationOnce(() => "これはmockです")
  }
})

vi.mock("../src/sample", () => {
  return {
    sample: mockSample
  }
})

test("sample", () => {
  expect(sample()).toBe("これはmockです")
  mockSample.mockImplementationOnce(() => "これはmock2です") // 追加
  expect(sample()).toBe("これはmock2です") // 追加
})

まとめると、Vitestでは次の順で評価されることがわかりました。(執筆時点)

  1. vitestからのimport(const { vi, test, expect } = await import('vitest')
  2. vi.hoisted
  3. vi.mock / vi.unmock
  4. そのほかのimport(dynamic importに変換される)

おわりに

以上、vi.hoistedの立ち位置を理解するために、ESMでのmockとimportの実行順の問題、Vitestのコード実行順を調査しました。

注意点として、vi.hoistedはまだ新しいメソッドなので、今後挙動が変わったりする可能性もあります。また、dynamic importへの変換の際のバグも報告されていました。(Destructuringで変換されるのでgetterが失われる。)
https://github.com/vitest-dev/vitest/issues/3300#issuecomment-1534149023

mock内で定義済みのMockを参照したい場合などに、参考にしていただければ幸いです。

今回のサンプルコードは下記で公開しています。
https://github.com/HosakaKeigo/vitest_hoisted

参考資料

Vitest公式

vi.mock

https://vitest.dev/api/vi.html#vi-mock

vi.hoisted

https://vitest.dev/api/vi.html#vi-hoisted

Feature Issue

https://github.com/vitest-dev/vitest/issues/3228

テストコード

vi.hoistedの仕様(spec)がわかります。
https://github.com/vitest-dev/vitest/blob/1ecbe74d453f5f856e4bfc7dd9b3097e939610ba/examples/mocks/test/hoisted.test.ts

Vitest

https://zenn.dev/you_5805/articles/vitest-mock-hoisting
https://willcodefor.beer/posts/vitesth

Jest

https://jestjs.io/ja/docs/ecmascript-modules
https://jestjs.io/ja/docs/manual-mocks#es-module-importを利用する

ESMとCommonJS

https://zenn.dev/yodaka/articles/596f441acf1cf3
https://azukiazusa.dev/blog/vite-require/

dynamic import

https://qiita.com/tonkotsuboy_com/items/f672de5fdd402be6f065#これまでのimport
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/import

脚注
  1. 本稿では扱いませんが、JestではESM対応として、jest.unstable_mockModuleを実験的に用意しているようです。 ↩︎

Discussion