- 技術 💻
Vitestモックの使い方|vi.mock・vi.fn・vi.spyOnの使い分け表
Vitest のモックとは、テスト中だけ関数やモジュールを「呼び出しを記録する偽物」に置き換え、外部への通信やメール送信などを起こさずに動きを確かめる仕組みです。 道具は大きく3つで、関数を新しく作る vi.fn、既存のメソッドを監視・差し替える vi.spyOn、import するモジュールごと置き換える vi.mock を使い分けます。
この記事では Vitest 5.0.3(2026年10月時点の npm 最新版) で、3つの使い分けを表にまとめ、すべてのコードを実際に実行したログを載せています。特に、mockClear・mockReset・mockRestore と vi.clearAllMocks・resetAllMocks・restoreAllMocks の違いを1つの表で実測し、vi.mock の巻き上げで出るエラー文と、同じファイル内の関数がモックされない現象を再現して直し方まで示します。Angular や Vue のコンポーネントテストの書き方は、それぞれ「Angular テスト Vitest 入門」「Vueのテストの書き方」の記事にまとめているので、ここではモックの使い方に集中します。
vi.fn・vi.spyOn・vi.mock の使い分け早見表
| やりたいこと | 使うもの | 書き方 |
|---|---|---|
| コールバック・引数の関数を偽物にする | vi.fn |
const onSave = vi.fn() |
| 偽物の戻り値を決める | vi.fn |
vi.fn(() => 10) / .mockReturnValue(10) |
| 既存オブジェクトのメソッドを監視だけする | vi.spyOn |
vi.spyOn(obj, 'method') |
| 既存メソッドを一時的に差し替えて後で戻す | vi.spyOn |
.mockReturnValue(x) → .mockRestore() |
| import しているモジュールを丸ごと置き換える | vi.mock |
vi.mock('./notify', () => ({ sendMail: vi.fn() })) |
| モジュールの一部だけ置き換える | vi.mock + importOriginal |
{ ...await importOriginal(), getTax: vi.fn() } |
| vi.mock の中でテスト側の変数を使う | vi.hoisted |
const { m } = vi.hoisted(() => ({ m: vi.fn() })) |
| モックに TypeScript の型を付ける | vi.mocked |
vi.mocked(sendMail).mockReturnValue('x') |
| fetch を置き換える | vi.stubGlobal / vi.spyOn(globalThis, 'fetch') |
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(res)) |
| setTimeout を早送りする | vi.useFakeTimers |
vi.advanceTimersByTime(500) |
| 「今」の日時を固定する | vi.setSystemTime |
vi.setSystemTime(new Date('2026-10-02')) |
| 環境変数を変える | vi.stubEnv |
vi.stubEnv('API_BASE', 'https://...') |
| テストごとに元に戻す | vi.restoreAllMocks など |
後述の比較表を参照 |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Vitestモックの使い方|vi.mock・vi.fn・vi.spyOnの使い分け表」
動作確認した環境
| 項目 | バージョン |
|---|---|
| Node.js | 22.22.2 |
| vitest | 5.0.3 |
| TypeScript | 7.0.2(tsc --noEmit で型チェック) |
npm init -y && npm pkg set type=module
npm i -D vitest typescript @types/node
npx vitest run --reporter=verbose # console.log の出力も表示される
--reporter=verbose を付けると、成功したテストの console.log も表示されます。 この記事のログはすべてこの形式で取得しました。
3つの違いを一言で
flowchart LR
A[テストしたい関数] --> B{偽物にしたいものは?}
B -->|引数で渡す関数| C[vi.fn]
B -->|既存オブジェクトのメソッド| D[vi.spyOn]
B -->|import しているモジュール| E[vi.mock]
D --> F[mockRestore で本物に戻せる]
E --> G[ファイル全体に効く・巻き上げられる]
| 比べる点 | vi.fn | vi.spyOn | vi.mock |
|---|---|---|---|
| 対象 | 新しい関数 | 既存のメソッド | モジュール全体 |
| 本物の処理 | なし(指定した実装) | 既定では本物が動く | なし(ファクトリの中身) |
| 効く範囲 | 渡した場所だけ | そのオブジェクト | そのテストファイル全体 |
| 元に戻す | ― | mockRestore() |
vi.doUnmock / ファイルを分ける |
| 書く位置 | どこでも | どこでも | どこに書いてもファイル先頭へ巻き上げ |
vi.fn と vi.spyOn の基本(実行ログ)
// test/basics.test.ts
import { describe, it, expect, vi } from 'vitest'
import * as notify from '../src/notify'
describe('vi.fn', () => {
it('戻り値を決めて、呼び出しを検証する', async () => {
const getStock = vi.fn((sku: string) => 0)
getStock.mockReturnValue(10).mockReturnValueOnce(1)
console.log('戻り値:', getStock('A-1'), getStock('A-2'), getStock('A-3'))
console.log('mock.calls:', JSON.stringify(getStock.mock.calls))
console.log('mock.results:', JSON.stringify(getStock.mock.results))
expect(getStock).toHaveBeenCalledTimes(3)
expect(getStock).toHaveBeenNthCalledWith(2, 'A-2')
expect(getStock).toHaveLastReturnedWith(10)
const load = vi.fn<() => Promise<string[]>>()
load.mockResolvedValueOnce(['ペン']).mockRejectedValueOnce(new Error('timeout'))
await expect(load()).resolves.toEqual(['ペン'])
await expect(load()).rejects.toThrow('timeout')
console.log('3回目(未設定):', load())
})
})
describe('vi.spyOn', () => {
it('本物を残したまま監視する/差し替える', () => {
const spy = vi.spyOn(notify, 'sendMail')
notify.sendMail('a@example.com', 'テスト1')
spy.mockImplementation(() => 'mocked')
console.log('差し替え後:', notify.sendMail('b@example.com', 'テスト2'))
spy.mockRestore()
console.log('mockRestore後:', notify.sendMail('c@example.com', 'テスト3'))
expect(spy).toHaveBeenCalledTimes(0) // restore で履歴も消える
})
})
// src/notify.ts
export function sendMail(to: string, subject: string): string {
console.log(`[本物] ${to} にメール送信: ${subject}`)
return 'sent'
}
戻り値: 1 10 10
mock.calls: [["A-1"],["A-2"],["A-3"]]
mock.results: [{"type":"return","value":1},{"type":"return","value":10},{"type":"return","value":10}]
3回目(未設定): undefined
[本物] a@example.com にメール送信: テスト1
差し替え後: mocked
[本物] c@example.com にメール送信: テスト3
mockRestore後: sent
✓ test/basics.test.ts > vi.fn > 戻り値を決めて、呼び出しを検証する 10ms
✓ test/basics.test.ts > vi.spyOn > 本物を残したまま監視する/差し替える 1ms
Once 付きの設定が先に使われ、使い切ると通常の設定に戻ります。 何も設定していない呼び出しは undefined を返します。vi.spyOn は差し替えるまでは本物が動く([本物] が出力されている)点が vi.fn との大きな違いです。
戻り値・実装を決めるメソッド一覧
| メソッド | 動き | 例 |
|---|---|---|
mockReturnValue(v) |
毎回 v を返す | 在庫数を固定 |
mockReturnValueOnce(v) |
次の1回だけ v | 1回目だけ別の値 |
mockResolvedValue(v) |
Promise.resolve(v) を返す |
API の成功 |
mockResolvedValueOnce(v) |
次の1回だけ resolve | 1回目成功 |
mockRejectedValue(e) |
Promise.reject(e) を返す |
API の失敗 |
mockRejectedValueOnce(e) |
次の1回だけ reject | 再試行のテスト |
mockImplementation(fn) |
実装を差し替える | 引数で戻り値を変える |
mockImplementationOnce(fn) |
次の1回だけ実装 | 順番に違う動き |
mockThrow(e) / mockThrowOnce(e) |
呼ぶと例外を投げる(v4.1 以降) | 同期関数のエラー |
withImplementation(fn, cb) |
cb の間だけ実装を差し替え | 一時的な変更 |
mockName(name) |
エラー表示の名前 | 失敗時に読みやすく |
mockThrowOnce と、Promise の結果を記録する mock.settledResults も確かめました。
const f = vi.fn().mockThrowOnce(new Error('在庫なし')).mockReturnValue('ok')
expect(() => f()).toThrow('在庫なし')
console.log('2回目:', f(), '/ results:', JSON.stringify(f.mock.results.map((r) => r.type)))
const g = vi.fn().mockResolvedValueOnce('A').mockRejectedValueOnce(new Error('NG'))
await g(); await g().catch(() => {})
console.log('settledResults:', JSON.stringify(g.mock.settledResults.map((r) => r.type)))
2回目: ok / results: ["throw","return"]
settledResults: ["fulfilled","rejected"]
呼び出しを検証するマッチャー一覧
| マッチャー | 確かめること |
|---|---|
toHaveBeenCalled() |
1回以上呼ばれた |
toHaveBeenCalledOnce() |
ちょうど1回 |
toHaveBeenCalledTimes(n) |
n 回 |
toHaveBeenCalledWith(...args) |
いずれかの呼び出しがこの引数 |
toHaveBeenLastCalledWith(...args) |
最後の呼び出しの引数 |
toHaveBeenNthCalledWith(n, ...args) |
n 回目の引数(1始まり) |
toHaveBeenCalledExactlyOnceWith(...args) |
1回だけ、かつこの引数 |
toHaveReturned() / toHaveReturnedWith(v) |
例外なく返った/v を返した |
toHaveLastReturnedWith(v) / toHaveNthReturnedWith(n, v) |
最後/n 回目の戻り値 |
toHaveResolved() / toHaveResolvedWith(v) |
Promise が resolve した/v で |
expect.any(Number) / expect.objectContaining({...}) |
引数の一部だけを比べる |
.mock のプロパティ |
中身 |
|---|---|
mock.calls |
呼び出しごとの引数の配列 |
mock.results |
{ type: 'return' | 'throw', value } |
mock.settledResults |
Promise の fulfilled / rejected |
mock.lastCall |
最後の呼び出しの引数 |
mock.instances / mock.contexts |
new で作られたインスタンス/this |
引数の一部だけ確かめたいときは expect.objectContaining を組み合わせると、日時などテストのたびに変わる値を無視できます。
mockClear・mockReset・mockRestore の違い(実測)
一番迷いやすいのがこの6つです。 vi.fn(() => '初期実装') に mockReturnValue('差し替え') を設定したものと、vi.spyOn(obj, 'hello') に同じく差し替えを設定したものを1回ずつ呼び、各メソッドを実行した後の状態を記録しました。
// test/reset.test.ts
import { it, vi } from 'vitest'
const obj = { hello: () => '本物' }
function check(label: string, action: (fn: any, spy: any) => void) {
const fn = vi.fn(() => '初期実装')
fn.mockReturnValue('差し替え')
fn()
const spy = vi.spyOn(obj, 'hello').mockReturnValue('差し替え')
obj.hello()
action(fn, spy)
const row = [label, `fn: calls=${fn.mock.calls.length} 戻り値=${fn()}`, `spyOn: calls=${spy.mock.calls.length} 戻り値=${obj.hello()} 本物に戻った=${obj.hello !== spy}`]
console.log(row.join(' | '))
vi.restoreAllMocks(); obj.hello = () => '本物'
}
it('個別メソッドと一括メソッドの比較', () => {
check('何もしない ', () => {})
check('mockClear ', (fn, spy) => { fn.mockClear(); spy.mockClear() })
check('mockReset ', (fn, spy) => { fn.mockReset(); spy.mockReset() })
check('mockRestore ', (fn, spy) => { fn.mockRestore(); spy.mockRestore() })
check('vi.clearAllMocks', () => vi.clearAllMocks())
check('vi.resetAllMocks', () => vi.resetAllMocks())
check('vi.restoreAllMocks', () => vi.restoreAllMocks())
})
何もしない | fn: calls=1 戻り値=差し替え | spyOn: calls=1 戻り値=差し替え 本物に戻った=false
mockClear | fn: calls=0 戻り値=差し替え | spyOn: calls=0 戻り値=差し替え 本物に戻った=false
mockReset | fn: calls=0 戻り値=初期実装 | spyOn: calls=0 戻り値=本物 本物に戻った=false
mockRestore | fn: calls=0 戻り値=初期実装 | spyOn: calls=0 戻り値=本物 本物に戻った=true
vi.clearAllMocks | fn: calls=0 戻り値=差し替え | spyOn: calls=0 戻り値=差し替え 本物に戻った=false
vi.resetAllMocks | fn: calls=0 戻り値=初期実装 | spyOn: calls=0 戻り値=本物 本物に戻った=false
vi.restoreAllMocks | fn: calls=1 戻り値=差し替え | spyOn: calls=1 戻り値=本物 本物に戻った=true
| メソッド | 呼び出し履歴 | 差し替えた戻り値 | vi.fn(初期実装) は | spyOn のメソッドは |
|---|---|---|---|---|
mockClear / vi.clearAllMocks |
消える | 残る | 差し替えのまま | spy のまま |
mockReset / vi.resetAllMocks |
消える | 消える | 初期実装に戻る | spy のまま(本物の処理を呼ぶ) |
mockRestore |
消える | 消える | 初期実装に戻る | 本物に戻る |
vi.restoreAllMocks |
残る | vi.fn は残る | 変わらない | 本物に戻る |
ポイントは3つです。
- mockReset は「何も返さない」ではなく、
vi.fn(実装)に渡した最初の実装に戻ります。 spyOn では本物の処理を呼ぶ状態に戻ります。 - vi.restoreAllMocks は vi.spyOn で作ったものを本物に戻すだけで、vi.fn や自動モックの履歴・実装には触れません(公式ドキュメントの記載どおりの結果でした)。
- テストごとに「履歴も実装もまっさら」にしたいなら、
vi.resetAllMocks()とvi.restoreAllMocks()を両方呼ぶのが確実です。
import { afterEach, vi } from 'vitest'
afterEach(() => {
vi.resetAllMocks() // 履歴と差し替えを消す(vi.fn は初期実装に)
vi.restoreAllMocks() // spyOn を本物に戻す
})
設定ファイルで毎回自動にすることもできます。各オプションの細かい挙動は公式ドキュメントで確認してください(この記事では設定ファイル版は未実行です)。
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: { clearMocks: true, mockReset: true, restoreMocks: true },
})
vi.mock でモジュールを置き換える
注文を受けるとメールを送る関数をテストします。テストしたいのは placeOrder で、sendMail は本当に送らないよう置き換えます。
// src/order.ts
import { sendMail } from './notify'
export function placeOrder(email: string, item: string) {
const status = sendMail(email, `${item} のご注文を受け付けました`)
return { item, status }
}
巻き上げ(hoisting)で出るエラーを再現
vi.mock は、どこに書いてもファイルの先頭(import より前)に移動して実行されます。 そのため、テストファイルで先に宣言した変数をファクトリの中で使うと、まだ初期化されていません。
// test/hoist-ng.test.ts(直す前)
import { it, expect, vi } from 'vitest'
import { placeOrder } from '../src/order'
const sendMailMock = vi.fn(() => 'mocked')
vi.mock('../src/notify', () => ({ sendMail: sendMailMock }))
it('注文するとメールが送られる', () => {
placeOrder('a@example.com', 'ノート')
expect(sendMailMock).toHaveBeenCalledOnce()
})
FAIL test/hoist-ng.test.ts [ test/hoist-ng.test.ts ]
Error: [vitest] There was an error when mocking a module. 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. Read more: https://vitest.dev/api/vi.html#vi-mock
❯ src/order.ts:1:1
1| import { sendMail } from './notify'
| ^
Caused by: ReferenceError: Cannot access 'sendMailMock' before initialization
❯ test/hoist-ng.test.ts:5:45
このコードは tsc の型チェックは通るため、実行して初めてエラーになります。 直し方は vi.hoisted で変数も一緒に先頭へ巻き上げることです。
// test/hoist-ok.test.ts(直した後)
import { it, expect, vi } from 'vitest'
import { placeOrder } from '../src/order'
const { sendMailMock } = vi.hoisted(() => ({ sendMailMock: vi.fn(() => 'mocked') }))
vi.mock('../src/notify', () => ({ sendMail: sendMailMock }))
it('注文するとメールが送られる', () => {
const r = placeOrder('a@example.com', 'ノート')
console.log(JSON.stringify(r), JSON.stringify(sendMailMock.mock.calls))
expect(sendMailMock).toHaveBeenCalledWith('a@example.com', 'ノート のご注文を受け付けました')
})
{"item":"ノート","status":"mocked"} [["a@example.com","ノート のご注文を受け付けました"]]
✓ test/hoist-ok.test.ts > 注文するとメールが送られる 3ms
自動モックと vi.mocked で型を付ける
ファクトリを渡さない vi.mock(path) は自動モックで、モジュール内の関数がすべて vi.fn() になります。 import した関数は元の型のままなので、vi.mocked() で包むと mockReturnValue などが型付きで使えます。
// test/mocked.test.ts
import { it, expect, vi, beforeEach } from 'vitest'
import { placeOrder } from '../src/order'
import { sendMail } from '../src/notify'
vi.mock('../src/notify') // ファクトリなし=自動モック
beforeEach(() => { vi.mocked(sendMail).mockReturnValue('queued') })
it('vi.mocked で型付きのモックとして扱う', () => {
const r = placeOrder('b@example.com', 'ペン')
console.log(JSON.stringify(r), 'isMock=', vi.isMockFunction(sendMail))
expect(vi.mocked(sendMail).mock.calls[0][0]).toBe('b@example.com')
})
{"item":"ペン","status":"queued"} isMock= true
✓ test/mocked.test.ts > vi.mocked で型付きのモックとして扱う 4ms
vi.mocked は型を付けるだけで、中身は渡したものをそのまま返します。 モックになっているのは vi.mock のおかげです。
同じファイル内の関数がモックされない現象
部分モック(importOriginal)で getTax を差し替えても、同じファイルの total の中から呼ばれる getTax は本物のままです。
// src/price.ts
export function getTax(price: number): number {
return Math.floor(price * 0.1)
}
export function total(price: number): number {
return price + getTax(price) // 同じファイル内の getTax を直接呼ぶ
}
// test/same-module.test.ts(直す前)
import { it, expect, vi } from 'vitest'
import { getTax, total } from '../src/price'
vi.mock('../src/price', async (importOriginal) => {
const mod = await importOriginal<typeof import('../src/price')>()
return { ...mod, getTax: vi.fn(() => 0) } // 部分モック:getTax だけ差し替え
})
it('getTax はモックされている', () => {
console.log('getTax(1000) =', getTax(1000))
expect(getTax(1000)).toBe(0)
})
it('total の中の getTax はモックされない', () => {
console.log('total(1000) =', total(1000))
expect(total(1000)).toBe(1000)
})
getTax(1000) = 0
total(1000) = 1100
✓ test/same-module.test.ts > getTax はモックされている 3ms
× test/same-module.test.ts > total の中の getTax はモックされない 6ms
→ expected 1100 to be 1000 // Object.is equality
vi.mock が置き換えるのは「モジュールの外に公開された出口(export)」です。 total は同じファイルの中にある getTax を直接呼んでいるので、出口を差し替えても影響しません。vi.spyOn(price, 'getTax') でも同じで、実行結果は price.getTax(1000) = 0 / price.total(1000) = 1100 でした。
直し方は、差し替えたい関数を別ファイルに分けることです。
// src/fixed/tax.ts
export function getTax(price: number): number {
return Math.floor(price * 0.1)
}
// src/fixed/price.ts
import { getTax } from './tax' // 別ファイルに分ける
export function total(price: number): number {
return price + getTax(price)
}
// test/same-module-fixed.test.ts(直した後)
import { it, expect, vi } from 'vitest'
import { total } from '../src/fixed/price'
vi.mock('../src/fixed/tax', () => ({ getTax: vi.fn(() => 0) }))
it('別ファイルに分けると total の中でもモックされる', () => {
console.log('total(1000) =', total(1000))
expect(total(1000)).toBe(1000)
})
total(1000) = 1000
✓ test/same-module-fixed.test.ts > 別ファイルに分けると total の中でもモックされる 3ms
| 対策 | 方法 | 向いている場面 |
|---|---|---|
| 別ファイルに分ける | 差し替えたい関数を別モジュールにして import | 外部通信・メールなど境界にある処理 |
| 引数で受け取る(依存性の注入) | total(price, getTax = defaultGetTax) |
テストで差し替える関数が少ない |
| モックせずに入力で確かめる | total(1000) が 1100 になることを検証 |
計算だけの純粋な関数 |
税額計算のような純粋な関数は、そもそもモックせずに結果を確かめるのが読みやすいテストです。モックは外部通信やメール送信など「テストで本当に動かしたくないもの」に絞ると、テストが壊れにくくなります。
fetch・タイマー・日付・環境変数のモック
// src/api.ts
export type User = { id: number; name: string }
export async function fetchUser(id: number): Promise<User> {
const res = await fetch(`https://api.example.com/users/${id}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
// src/util.ts
export function isExpired(deadline: string): boolean {
return Date.now() > new Date(deadline).getTime()
}
export function debounce(fn: () => void, ms: number) {
let t: ReturnType<typeof setTimeout> | undefined
return () => { clearTimeout(t); t = setTimeout(fn, ms) }
}
export function apiBase(): string {
return process.env.API_BASE ?? 'http://localhost:3000'
}
// test/env.test.ts
import { describe, it, expect, vi, afterEach } from 'vitest'
import { fetchUser } from '../src/api'
import { isExpired, debounce, apiBase } from '../src/util'
afterEach(() => {
vi.useRealTimers()
vi.unstubAllEnvs()
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe('fetch', () => {
it('vi.stubGlobal で fetch を差し替える', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ id: 1, name: '佐藤' }), { status: 200, headers: { 'content-type': 'application/json' } }),
)
vi.stubGlobal('fetch', fetchMock)
console.log('取得結果:', JSON.stringify(await fetchUser(1)))
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/users/1')
})
it('vi.spyOn(globalThis, "fetch") でエラー応答', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('not found', { status: 404 }))
await expect(fetchUser(99)).rejects.toThrow('HTTP 404')
console.log('404 のとき:', await fetchUser(99).catch((e) => e.message))
})
})
describe('タイマーと日付', () => {
it('vi.useFakeTimers で debounce を進める', () => {
vi.useFakeTimers()
const save = vi.fn()
const debounced = debounce(save, 500)
debounced(); debounced(); debounced()
vi.advanceTimersByTime(499)
console.log('499ms 後の呼び出し回数:', save.mock.calls.length)
vi.advanceTimersByTime(1)
console.log('500ms 後の呼び出し回数:', save.mock.calls.length)
expect(save).toHaveBeenCalledOnce()
})
it('vi.setSystemTime で「今」を固定する', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-10-02T09:00:00+09:00'))
console.log('new Date():', new Date().toISOString())
console.log('期限 2026-10-01 は切れている?', isExpired('2026-10-01T23:59:59+09:00'))
console.log('期限 2026-10-03 は切れている?', isExpired('2026-10-03T00:00:00+09:00'))
})
})
describe('環境変数', () => {
it('vi.stubEnv で process.env を一時的に変える', () => {
console.log('既定:', apiBase())
vi.stubEnv('API_BASE', 'https://staging.example.com')
console.log('stubEnv 後:', apiBase())
vi.unstubAllEnvs()
console.log('unstubAllEnvs 後:', apiBase())
})
})
取得結果: {"id":1,"name":"佐藤"}
404 のとき: HTTP 404
499ms 後の呼び出し回数: 0
500ms 後の呼び出し回数: 1
new Date(): 2026-10-02T00:00:00.000Z
期限 2026-10-01 は切れている? true
期限 2026-10-03 は切れている? false
既定: http://localhost:3000
stubEnv 後: https://staging.example.com
unstubAllEnvs 後: http://localhost:3000
✓ test/env.test.ts > fetch > vi.stubGlobal で fetch を差し替える 52ms
✓ test/env.test.ts > fetch > vi.spyOn(globalThis, "fetch") でエラー応答 2ms
✓ test/env.test.ts > タイマーと日付 > vi.useFakeTimers で debounce を進める 5ms
✓ test/env.test.ts > タイマーと日付 > vi.setSystemTime で「今」を固定する 2ms
✓ test/env.test.ts > 環境変数 > vi.stubEnv で process.env を一時的に変える 1ms
| 対象 | 置き換える | 元に戻す | 補足 |
|---|---|---|---|
| fetch | vi.stubGlobal('fetch', vi.fn()) |
vi.unstubAllGlobals() |
Node 18 以降は Response が標準で使える |
| fetch(監視しつつ) | vi.spyOn(globalThis, 'fetch') |
vi.restoreAllMocks() |
呼び出しも検証できる |
| setTimeout / setInterval | vi.useFakeTimers() → advanceTimersByTime(ms) |
vi.useRealTimers() |
runAllTimers() で全部進める |
| 今の日時 | vi.setSystemTime(date) |
vi.useRealTimers() |
表示は UTC(toISOString) |
| 環境変数 | vi.stubEnv(name, value) |
vi.unstubAllEnvs() |
import.meta.env も対象 |
debounce は 499ms では0回、500ms でちょうど1回になり、3回続けて呼んでも最後の1回だけ実行されることが確かめられました。偽のタイマーは既定で setTimeout・setInterval・setImmediate・Date などを置き換え、process.nextTick と queueMicrotask は toFake で指定したときだけ置き換えます(公式ドキュメントの記載)。
setSystemTime に +09:00 付きで渡した時刻は、toISOString() では UTC(00:00:00.000Z)で表示されます。 日付の境目を確かめるテストでは、タイムゾーンを明記して書くと読み違いを防げます。
よくあるエラーと直し方
| エラー・症状 | 原因 | 直し方 |
|---|---|---|
Cannot access 'xxx' before initialization |
vi.mock のファクトリで外の変数を参照 | vi.hoisted で一緒に巻き上げる |
| 部分モックしたのに本物が動く | 同じファイル内から直接呼んでいる | 別ファイルに分ける/引数で受け取る |
| 前のテストの呼び出し回数が残る | 履歴をクリアしていない | afterEach で vi.clearAllMocks() など |
| restoreAllMocks したのに vi.fn の戻り値が残る | restoreAllMocks は spyOn 専用 | vi.resetAllMocks() も呼ぶ |
mockReset 後に undefined を期待したら初期実装が返った |
mockReset は vi.fn(実装) の実装に戻す |
期待値を初期実装に合わせる |
| fetch のモックが次のテストに残る | stubGlobal を戻していない | vi.unstubAllGlobals() |
| 偽タイマーのまま他のテストが止まる | useRealTimers を呼んでいない | afterEach(() => vi.useRealTimers()) |
よくある質問
Q. vi.fn と vi.spyOn はどちらを使えばよいですか? A. テスト対象に引数として渡す関数なら vi.fn、すでにあるオブジェクトのメソッドなら vi.spyOn です。spyOn は差し替えるまで本物が動くので、「呼ばれたかだけ確かめたい」ときにも使えます。
Q. vi.mock を test の中に書いても、そのテストだけに効きますか?
A. 効きません。vi.mock はファイルの先頭に巻き上げられ、ファイル全体に効きます。 テストごとに戻り値を変えたいときは、vi.mock で関数を vi.fn() にしておき、各テストで vi.mocked(fn).mockReturnValue(...) を設定します。
Q. mockReset と mockRestore はどちらを使えばよいですか?
A. 実測の表のとおり、vi.fn には mockReset(初期実装に戻る)、vi.spyOn で本物に戻したいときは mockRestore です。一括で戻すなら vi.resetAllMocks() と vi.restoreAllMocks() を組み合わせます。
Q. 外部 API の呼び出しはすべてモックするべきですか? A. 単体テストでは fetch や API クライアントをモックすると速く安定します。API の形が変わったことにも気づきたい場合は、モックを使わない結合テストも別に用意すると安心です。
まとめ
- 引数の関数は vi.fn、既存メソッドは vi.spyOn、import するモジュールは vi.mock
- vi.spyOn は差し替えるまで本物が動く。mockRestore で本物に戻る
- mockClear は履歴だけ、mockReset は実装も(vi.fn は初期実装へ)、restoreAllMocks は spyOn だけを本物に戻す
- vi.mock はファイル先頭に巻き上げられる。外の変数は vi.hoisted で渡す
- 同じファイル内から呼ぶ関数はモックされない。別ファイルに分けて差し替える
- fetch は stubGlobal、タイマーは useFakeTimers、日時は setSystemTime、環境変数は stubEnv
- afterEach で戻す処理をまとめると、テスト同士が影響し合わない
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Vitestモックの使い方|vi.mock・vi.fn・vi.spyOnの使い分け表」
株式会社RJCでは、Vitest のモックを使ったテストの書き方を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。