ESMのmock巻き上げ問題とVitestのvi.hoistedについて
はじめに
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の基本知識がある
- 下記の記事など参考になる記事がたくさんありますのでご覧ください。
-
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")は静的で、常にモジュールが 読み込まれた時点で評価される結果となります。
実際に動作検証している記事がわかりやすかったので挙げておきます。
つまり、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する関数として下記を用意しました。
export function sample() {
return "これはmockではありません"
}
vi.hoisted
❌
サンプル1: static import without 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を行うソースコード
すると、下記のように変換されていました。(コメントは筆者)
変換後のコード
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に変換
- トップレベルに以下の順で移動する
- vitestからのimport(
const { vi, test, expect } = await import('vitest')
) - vi.mock / vi.unmock
- そのほかのimport
- vitestからの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
の出番です。
実際にvi.hoisted
を使ったサンプルで検証してみました。
vi.hoisted
✅
サンプル2: With 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関数であるmockSample
をvi.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では次の順で評価されることがわかりました。(執筆時点)
- vitestからのimport(
const { vi, test, expect } = await import('vitest')
) - vi.hoisted
- vi.mock / vi.unmock
- そのほかのimport(dynamic importに変換される)
おわりに
以上、vi.hoisted
の立ち位置を理解するために、ESMでのmockとimportの実行順の問題、Vitestのコード実行順を調査しました。
注意点として、vi.hoisted
はまだ新しいメソッドなので、今後挙動が変わったりする可能性もあります。また、dynamic importへの変換の際のバグも報告されていました。(Destructuringで変換されるのでgetterが失われる。)
mock内で定義済みのMockを参照したい場合などに、参考にしていただければ幸いです。
今回のサンプルコードは下記で公開しています。
参考資料
Vitest公式
vi.mock
vi.hoisted
Feature Issue
テストコード
vi.hoisted
の仕様(spec)がわかります。
Vitest
Jest
ESMとCommonJS
dynamic import
-
本稿では扱いませんが、JestではESM対応として、
jest.unstable_mockModule
を実験的に用意しているようです。 ↩︎
Discussion