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.spyOn と vi.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.ts で vite.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.mock と vi.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.ts に test.pool: 'forks' や globals: true なし)、vi.mock の動作が変わることがある。
確認方法
// vitest.config.ts
export default defineConfig({
test: {
globals: true, // これがないと vi.mock が使えない場合がある
environment: 'node',
},
})
または vite.config.ts の ssr.noExternal や optimizeDeps.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つだ。
- hoisting + 変数参照エラー → factory関数内で直接
vi.fn()を書く - デフォルトエクスポートのモック漏れ →
{ default: vi.fn() }を明示する - パスエイリアス未設定 →
vitest.config.tsにresolve.aliasを設定する - 部分モックで他のエクスポートが undefined →
importOriginalでスプレッドする vi.mockとvi.spyOnの混同 → スコープと用途で使い分ける
特に hoisting の問題は Jest から移行してきたエンジニアでもハマりやすいポイントだ。「factory関数の中では外の変数を参照できない」というルールを覚えておくだけで、多くのエラーを事前に回避できる。
モックが差し替わらない場合は、まず vi.isMockFunction() で実際にモックが適用されているかを確認してみよう。
