iimon TECH BLOG

iimonエンジニアが得られた経験や知識を共有して世の中をイイモンにしていくためのブログです

vitestの巻き上げ

こんにちは、kogureです。 最近はトレッキングに興味があります。 皆さんもぜひ登りましょう、楽しいですよ。

トレッキングの魅力もお伝えしたいのですが今回はvitestの巻き上げについて紹介します。

今年から本格的にテストコードを書くようになりました。 typescriptのテストを書くのはそれが初めてで当初、詰まったエラーを紹介させていただきます。 原因がわかってしまえばとても単純明快なことだったのですが、当時はまだまだ慣れていないこともあり解決するのに時間がかかりました。

サンプルコードなので実際のテストとは異なりますが、以下のようなテストを書いたらエラーが発生しました。

・サンプル

const mock = vi.fn();

describe('', () => {
  it('', () => {
    vi.mock('./hoge', () => ({
      call: mock,
    }));

    const instance = new hoge();

    instance.call();

    expect(mock).toHaveBeenCalled();
  });
});

・エラー

Caused by: ReferenceError: Cannot access 'mock' before initialization

初期化されてないからアクセスできない? mockはテストより前に定義しているのになぜ??

と当時は思っていました。

このとき私は巻き上げという概念を知りませんでした。

Vitestの巻き上げ(Hoisting)を理解する

エラーメッセージをよく読むと、重要なヒントが隠されていました。

[vitest] If you are using "vi.mock" factory, make sure there are no top level variables inside, since this call is hoisted to top of the file.

「this call is hoisted to top of the file」 - つまり、vi.mock()の呼び出しがファイルの最上部に巻き上げられる、ということでした。

そしてvitestのドキュメントからvi.mockを確認すると以下のように書かれています。

Substitutes all imported modules from provided path with another module. You can use configured Vite aliases inside a path. The call to vi.mock is hoisted, so it doesn't matter where you call it. It will always be executed before all imports. If you need to reference some variables outside of its scope, you can define them inside vi.hoisted and reference them inside vi.mock.

指定されたパスからインポートされたすべてのモジュールを別のモジュールで置き換えます。パスの中で設定されたViteエイリアスを使用することができます。vi.mockの呼び出しはホイスト(巻き上げ)されるので、どこで呼び出してもかまいません。これは常にすべてのインポートの前に実行されます。そのスコープ外で変数を参照する必要がある場合は、vi.hoisted内で変数を定義し、vi.mock内で参照することができます。

vitest.dev

実際の実行順序

私が書いたコードの実際の実行順序は以下のようになっていました

// 実際の実行順序
// 1. vi.mock()が最初に実行される(巻き上げ)
vi.mock('./hoge', () => ({
  call: mock,  // ← この時点でmockはまだ存在しない!
}));

// 2. その後で変数宣言が実行される
const mock = vi.fn();

見た目の順序と実際の実行順序が異なっていたのです。 ドキュメントをしっかりと読んでいれば防げていたのですが、 当時は他の人の書いたテストコードを参考にノリで書いていて横着してなんとなくで直そうとしていたのも時間がかかった要因でした。

解決方法

方法1: vi.hoisted()を使用する

// vi.hoisted()内で定義した値は確実に最初に実行される
const mock = vi.hoisted(() => vi.fn());

describe('', () => {
  it('', () => {
    vi.mock('./hoge', () => ({
      call: mock, // 安全に使用可能
    }));

    const instance = new hoge();
    instance.call();

    expect(mock).toHaveBeenCalled();
  });
});

方法2: ファクトリ関数内でモック作成

describe('', () => {
  it('', () => {
    vi.mock('./hoge', () => ({
      call: vi.fn(), // 外部変数を使わず、ここで直接作成
    }));

    // モックしたモジュールをインポートして検証
    const { call } = await import('./hoge');
    
    const instance = new hoge();
    instance.call();

    expect(call).toHaveBeenCalled();
  });
});

方法3: vi.spyOn()を使用する

describe('', () => {
  it('', () => {
    const instance = new hoge();
    
    // 実際のメソッドをスパイする
    const spy = vi.spyOn(instance, 'call');
    
    instance.call();

    expect(spy).toHaveBeenCalled();
  });
});

巻き上げを可視化してみる

理解を深めるため、実際にコンソールログで実行順序を確認してみました

console.log('1. ファイル読み込み開始');

const config = vi.hoisted(() => {
  console.log('2. hoisted内のコード - 最初に実行される');
  return { value: 'test' };
});

console.log('3. hoisted定義後');

vi.mock('./module', () => ({
  getValue: () => config.value
}));

console.log('4. mock定義後');

describe('test', () => {
  console.log('5. テスト開始');
  
  it('example', () => {
    console.log('6. テスト実行');
    // テスト内容...
  });
});

実行結果:

学んだこと

  • vi.mock()は常に巻き上げられる - これは仕様であり、回避できない
  • 外部変数を使用したい場合はvi.hoisted()が必須
  • エラーメッセージをしっかり読む重要性

まとめ

Vitestの巻き上げは最初は戸惑うかもしれませんが、一度理解すれば非常に強力な機能です。特にvi.hoisted()を使うことで、複雑なモック設定も安全に管理できるようになります。 同じような問題に遭遇した方の参考になれば幸いです。テストコードを書く際は、ツールの仕様をしっかり理解することの大切さを改めて実感しました。

最後まで読んでくださりありがとうございます!

この記事を読んで興味を持って下さった方がいらっしゃればカジュアルにお話させていただきたく、是非ご応募をお願いします。
iimon採用サイト / Wantedly / Green