← 記事一覧に戻る

Vitest で vi.mock が動かない・モックが差し替わらない時の原因と対策【ESM hoisting / factory関数 / スパイ】

vi.mock が動かない時に確認すること(結論)

Vitest で vi.mock が動かない原因は大きく5つある。

原因 症状 対策
ESM hoisting + 変数参照 ReferenceError: Cannot access 'X' before initialization factory内で直接 vi.fn() を書く
デフォルトエクスポートのモック漏れ モジュールは差し替わるが関数が undefined { default: vi.fn() } を明示する
パスエイリアス未設定 モックが適用されない・元の実装が呼ばれる vitest.config.ts にエイリアスを設定
部分モックで importOriginal 未使用 他のエクスポートが undefined になる vi.importActual で元の実装を引き継ぐ
vi.spyOnvi.mock の混同 モック後に元に戻らない・型エラー 用途で使い分ける

なぜ vi.mock は「hoisting」するのか

Vitest の vi.mockファイルの先頭に自動的に巻き上げられる(hoisting) 仕組みになっている。これは Jest の jest.mock と同じ動作で、テストファイルの import 文よりも前にモックの登録が実行されるよう、コードが変換されるためだ。

この hoisting が原因でハマるケースが最も多い。

動かないコード例(よくあるミス)

// ❌ これは動かない
const mockFetch = vi.fn()

vi.mock('./api', () => ({
  fetchUser: mockFetch, // ReferenceError: Cannot access 'mockFetch' before initialization
}))

vi.mock はファイルの先頭に巻き上げられるため、const mockFetch = vi.fn() の宣言より前に実行される。結果として mockFetch は初期化前の参照になり、エラーが発生する。

動くコード例

// ✅ factory関数の中で vi.fn() を直接書く
vi.mock('./api', () => ({
  fetchUser: vi.fn(),
}))

// テスト内でモック関数を取得して設定する
import { fetchUser } from './api'

test('ユーザー取得', async () => {
  vi.mocked(fetchUser).mockResolvedValue({ id: 1, name: 'Alice' })
  // ...
})

ポイント: factory関数の中では vi.fn() を直接書く。テスト内でモック関数を操作したい場合は vi.mocked() でラップして使う。


原因1:デフォルトエクスポートのモックが差し替わらない

デフォルトエクスポート(export default)のモックは、名前付きエクスポートと書き方が違う。

動かないコード例

// src/utils/logger.ts
export default function logger(message: string) {
  console.log(message)
}
// ❌ デフォルトエクスポートのモックが差し替わらない
vi.mock('./utils/logger', () => {
  return vi.fn() // これでは default プロパティが undefined になる
})

動くコード例

// ✅ { default: vi.fn() } を明示する
vi.mock('./utils/logger', () => ({
  default: vi.fn(),
}))

import logger from './utils/logger'

test('ログが出力される', () => {
  // logger は vi.fn() として差し替わっている
  logger('test')
  expect(logger).toHaveBeenCalledWith('test')
})

ポイント: factory関数は module オブジェクトを返す必要がある。デフォルトエクスポートは { default: ... } の形で返す。


原因2:パスエイリアスが解決できていない

TypeScript プロジェクトで @/ のようなパスエイリアスを使っている場合、vitest.config.ts にも同じエイリアスを設定しないとモックが正しく適用されない。

症状

// ❌ vi.mock のパスとインポートのパスが一致しない
vi.mock('@/utils/api', () => ({ fetchUser: vi.fn() }))

// テスト対象のコード内では '@/utils/api' でインポートされているが、
// モックが適用されずに本物の実装が呼ばれてしまう

原因と対策

// vitest.config.ts
import { defineConfig } from 'vitest/config'
import { resolve } from 'path'

export default defineConfig({
  test: {
    // ...
  },
  resolve: {
    alias: {
      // tsconfig.json の paths と同じエイリアスを設定する
      '@': resolve(__dirname, './src'),
    },
  },
})

または vite.config.ts にすでにエイリアスを設定している場合は、vitest.config.tsvite.config.ts を読み込む方法が簡単だ。

// vitest.config.ts
import { defineConfig, mergeConfig } from 'vitest/config'
import viteConfig from './vite.config'

export default mergeConfig(viteConfig, defineConfig({
  test: {
    environment: 'jsdom',
    globals: true,
  },
}))

ポイント: vi.mock のパス解決は Vite の resolve.alias 設定に依存する。Vite 設定とテスト設定を統一しておくと問題が起きにくい。


原因3:部分モックで他のエクスポートが undefined になる

モジュールの一部だけモックして、他のエクスポートはそのまま使いたい場合、factory関数を省略すると全エクスポートが自動的に vi.fn() に置き換わる。特定のエクスポートだけモックし、残りは実装を維持するには vi.importActual を使う。

症状

// src/utils/math.ts
export function add(a: number, b: number) { return a + b }
export function multiply(a: number, b: number) { return a * b }
export const PI = 3.14159
// ❌ factory なしで vi.mock するとすべての export が vi.fn() になる
vi.mock('./utils/math')

test('multiply だけモック', () => {
  const { add, PI } = await import('./utils/math')
  console.log(add(1, 2))  // → undefined(vi.fn() の戻り値)
  console.log(PI)          // → undefined
})

動くコード例

// ✅ vi.importActual で元の実装を引き継ぐ
vi.mock('./utils/math', async (importOriginal) => {
  const actual = await importOriginal<typeof import('./utils/math')>()
  return {
    ...actual,           // 元の実装をスプレッドで引き継ぐ
    multiply: vi.fn(),   // multiply だけモックに差し替える
  }
})

test('multiply だけモック', async () => {
  const { multiply, add, PI } = await import('./utils/math')
  vi.mocked(multiply).mockReturnValue(100)

  expect(multiply(2, 3)).toBe(100)  // モックが適用される
  expect(add(1, 2)).toBe(3)        // 本物の実装が呼ばれる
  expect(PI).toBe(3.14159)         // 定数も維持される
})

ポイント: factory関数の引数として importOriginal を受け取り、await importOriginal() で本物のモジュールを取得してからスプレッドで広げる。TypeScript の型は importOriginal<typeof import('./path')>() で付けられる。


原因4:vi.mock と vi.spyOn の使い分けが間違っている

vi.mockvi.spyOn はどちらも「モック」に使えるが、用途が異なる。

vi.mock vi.spyOn
スコープ ファイル全体 特定のオブジェクトのメソッド
対象 モジュール全体 オブジェクトのプロパティ
元の実装 デフォルトで差し替わる デフォルトで元の実装を呼ぶ
リセット vi.clearAllMocks() spy.mockRestore()
hoisting される されない

vi.spyOn を使うべきケース

// ✅ 既存オブジェクトのメソッドを一時的にモックする
import * as fs from 'fs'

test('ファイル読み込みのテスト', () => {
  const spy = vi.spyOn(fs, 'readFileSync').mockReturnValue('mocked content' as any)

  const result = readConfig('./config.json')
  expect(result).toBe('mocked content')

  spy.mockRestore() // 元の実装に戻す
})

vi.mock を使うべきケース

// ✅ モジュール全体を差し替える(importされる全テストに適用)
vi.mock('axios', () => ({
  default: {
    get: vi.fn(),
    post: vi.fn(),
  },
}))

よくあるハマりポイント: vi.spyOn は hoisting されないため、テストの beforeEach や各テスト内で呼び出す必要がある。vi.mock のつもりで vi.spyOn をファイルの先頭で書いても、import より後に実行されて効かない場合がある。


原因5:ESM モードで vi.mock が効かない

Vitest をネイティブ ESM モードで動かしている場合(vitest.config.tstest.pool: 'forks'globals: true なし)、vi.mock の動作が変わることがある。

確認方法

// vitest.config.ts
export default defineConfig({
  test: {
    globals: true, // これがないと vi.mock が使えない場合がある
    environment: 'node',
  },
})

または vite.config.tsssr.noExternaloptimizeDeps.exclude でモック対象パッケージが除外されているか確認する。

外部パッケージのモック

// ✅ 外部パッケージ(例: date-fns)のモック
vi.mock('date-fns', async (importOriginal) => {
  const actual = await importOriginal<typeof import('date-fns')>()
  return {
    ...actual,
    format: vi.fn().mockReturnValue('2026-01-01'),
  }
})

vi.mock のデバッグに役立つテクニック

モックが適用されているか確認する

import { fetchUser } from './api'

vi.mock('./api')

test('モックが適用されているか確認', () => {
  // vi.isMockFunction で確認できる
  console.log(vi.isMockFunction(fetchUser)) // true なら適用されている

  // mockImplementation の前に呼ぶ必要がある
  vi.mocked(fetchUser).mockResolvedValue({ id: 1, name: 'Alice' })
})

afterEach でモックをリセットする

// vitest.config.ts で自動クリアを設定
export default defineConfig({
  test: {
    clearMocks: true,     // 各テスト後に mock.calls をクリア
    resetMocks: true,     // 各テスト後に mockImplementation をリセット
    restoreMocks: true,   // 各テスト後に vi.spyOn を元に戻す
  },
})

または個別にリセットする場合:

afterEach(() => {
  vi.clearAllMocks()    // 呼び出し履歴だけクリア
  // vi.resetAllMocks() // 実装もリセット
  // vi.restoreAllMocks() // spyOn を元に戻す
})

まとめ

Vitest の vi.mock でハマる原因は主に5つだ。

  1. hoisting + 変数参照エラー → factory関数内で直接 vi.fn() を書く
  2. デフォルトエクスポートのモック漏れ{ default: vi.fn() } を明示する
  3. パスエイリアス未設定vitest.config.tsresolve.alias を設定する
  4. 部分モックで他のエクスポートが undefinedimportOriginal でスプレッドする
  5. vi.mockvi.spyOn の混同 → スコープと用途で使い分ける

特に hoisting の問題は Jest から移行してきたエンジニアでもハマりやすいポイントだ。「factory関数の中では外の変数を参照できない」というルールを覚えておくだけで、多くのエラーを事前に回避できる。

モックが差し替わらない場合は、まず vi.isMockFunction() で実際にモックが適用されているかを確認してみよう。