VitestはJestの書き味そのままにViteの速度で走るテストランナーとして一気に定着しました。ただ、Jestからの移植レベルで止まっていて、Vitest固有の便利機能や設計上のツボを踏み切れていない現場も多い印象です。
この記事では、公式ドキュメントを一通り読んだ人でも意外と知らないことが多い、実務で効いてくるTipsを整理していきます。
対象は、JavaScript/TypeScriptでテストを書いた経験はあるけれど、Vitestを設計の道具として使いこなせている自信はまだない、というレベル感の方を想定しています。
1. expect の差分表示 — 素直に書けばよく効く
Vitestの expect は失敗時に、深い構造でも見やすいdiffを出してくれます。オブジェクト比較で toEqual の中を手で分解して書く必要はありません。
import { expect, test } from 'vitest'
test('user shape', () => {
const expected = { name: 'Alice', age: 30, role: 'admin' }
expect(buildUser()).toEqual(expected)
})
toBe(参照一致)と toEqual(構造一致)の使い分けだけは意識しておくと、後から読んだ人の混乱がぐっと減ります。
2. vitest.config.ts は環境の設計図
Vitestの設定はViteの設定と統合されているのが強みです。逆に言うと、ここに何を書いたかがテストの前提条件そのものになります。
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node', // 'jsdom' / 'happy-dom' / 'edge-runtime'
globals: false, // describe/it/expect をimportして使う派を推奨
setupFiles: ['./test/setup.ts'],
include: ['src/**/*.{test,spec}.{ts,tsx}'],
coverage: { provider: 'v8', reporter: ['text', 'html'] },
},
})
globals: false にして毎ファイル import { describe, it, expect } from 'vitest' を書くほうが、IDE補完・型・エディタジャンプが素直に効きます。プロジェクトの規模が大きくなるほどこれが効いてきます。
3. environment はファイル単位で切り替える
プロジェクト全体をNode環境やjsdomで決め打ちする必要はありません。ファイル冒頭のdocblockで局所的に切り替えられます。
// @vitest-environment jsdom
import { render } from '@testing-library/react'
UIコンポーネントのテストだけjsdomにして、それ以外はNodeで速く回す戦略が現実的です。happy-dom はjsdomより高速でだいたい代替できますが、DOM APIの実装差で落ちるライブラリもあるので、依存ライブラリのCIログを一度は確認しておくと安心です。
4. beforeEach より fixture-like なヘルパー関数
Vitestには beforeAll / beforeEach / afterEach / afterAll が揃っていますが、実務ではセットアップと後始末をペアにしたヘルパー関数を返す設計のほうがだいたい読みやすくなります。
function useTempDir() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vt-'))
afterEach(() => fs.rmSync(dir, { recursive: true, force: true }))
return dir
}
test('writes config', () => {
const dir = useTempDir()
fs.writeFileSync(path.join(dir, 'c.json'), '{}')
// ...
})
beforeEach に外部変数を持たせて共有するより、呼んだテストの中でだけ効く形にしたほうが、テストの独立性が保ちやすいです。
5. vi.fn() は作った時ではなく呼ばれ方で検証する
モック関数を作ることが目的化しがちですが、価値は呼び出しの検証です。
const onSave = vi.fn()
render(<Form onSave={onSave} />)
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(onSave).toHaveBeenCalledTimes(1)
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Alice' }))
toHaveBeenCalledWith の引数に部分マッチ(expect.objectContaining / expect.any(String))を使えると、実装の枝葉に振り回されない堅牢なテストになります。
6. vi.mock はモジュールの契約だけを差し替える
モックは書きすぎるとテストが壊れやすくなります。外部との境界(HTTPクライアント、DB、時刻、ランダム)だけをモックし、内部ロジックはモックしないのが基本方針です。
vi.mock('./api', () => ({
fetchUser: vi.fn(async (id: string) => ({ id, name: 'stub' })),
}))
Vitestの vi.mock はホイスティングされてimport前に評価される点に注意が必要です。動的に切り替えたい値は vi.hoisted で持ち上げます。
const { user } = vi.hoisted(() => ({ user: { id: '1', name: 'Alice' } }))
vi.mock('./api', () => ({ fetchUser: vi.fn(async () => user) }))
これを知らずに変数がundefinedになって時間を溶かすのは、Vitestあるあるです。
7. vi.spyOn は実装を残したまま監視する
完全にモックで置き換えるのはやりすぎで、呼ばれ方だけ検証したいときは spyOn を使います。
const spy = vi.spyOn(logger, 'warn')
run()
expect(spy).toHaveBeenCalledWith(expect.stringContaining('deprecated'))
mockImplementation を後付けすれば途中から挙動を差し替えることもできますが、元の実装が動くことが spyOn の存在価値なので、必要な瞬間だけ差し替えるようにします。
8. vi.useFakeTimers() で時間を握る
setTimeout / setInterval / Date.now を絡めたテストは、フェイクタイマーで完全に決定論にできます。
beforeEach(() => vi.useFakeTimers({ now: new Date('2026-08-07T00:00:00Z') }))
afterEach(() => vi.useRealTimers())
test('expires after 30s', () => {
const token = issueToken()
vi.advanceTimersByTime(30_000)
expect(isExpired(token)).toBe(true)
})
vi.setSystemTime で途中から時計を進めることもできます。await を絡めたい場合は vi.advanceTimersByTimeAsync を使うのがポイントです。
9. パラメトライズは test.each で表にする
分岐だらけのテストは test.each で表に畳み直すと、レビュー時の見通しが劇的に良くなります。
test.each([
{ raw: '2026-01-01', expected: true, name: 'new_year' },
{ raw: '2026-02-29', expected: false, name: 'not_leap' },
{ raw: '2024-02-29', expected: true, name: 'leap_day' },
{ raw: 'not-a-date', expected: false, name: 'invalid' },
])('isValidDate($name)', ({ raw, expected }) => {
expect(isValidDate(raw)).toBe(expected)
})
$name のようにテンプレート補間で分かりやすい失敗ラベルを出せるのがVitestのいいところです。CIログでの原因調査スピードが変わります。
10. describe.each で環境の直積を作る
複数の環境や設定を掛け合わせたいときは describe.each を使います。
describe.each(['sqlite', 'postgres'])('storage=%s', (kind) => {
test('put/get', async () => {
const store = await createStore(kind)
await store.put('k', 'v')
expect(await store.get('k')).toBe('v')
})
})
同じ契約を守るべき複数の実装があるとき、これで一気にカバーできます。
11. test.skip / test.todo / test.fails を使い分ける
-
test.skip: 一時的に無効化(理由をコメントで残す) -
test.todo: まだ書いていない(タイトルだけ登録して抜け漏れ防止) -
test.fails: 落ちることが期待される(バグの再現テストとして活用)
test.todo('should retry on 5xx')
test.fails('known bug: precision on very small floats', () => {
expect(0.1 + 0.2).toBe(0.3)
})
test.fails は特に有能で、バグが直った瞬間にCIが赤くなって気づける仕組みとして機能します。
12. test.concurrent でファイル内も並列化
Vitestは既定でファイル単位に並列実行しますが、同一ファイル内は逐次実行です。副作用の無いテストなら .concurrent でさらに並列化できます。
describe.concurrent('pure', () => {
test('a', async () => { /* ... */ })
test('b', async () => { /* ... */ })
})
ただしモジュールモックや共有状態があると壊れます。純粋関数の集まりのファイルにだけ使うのが安全です。
13. expect.soft で一発で落ちないアサーションを書く
デフォルトの expect は失敗した瞬間に停止しますが、expect.soft を使うとそのテスト内では失敗を蓄積して最後にまとめて報告できます。
test('response shape', () => {
const r = getResponse()
expect.soft(r.status).toBe(200)
expect.soft(r.headers['content-type']).toContain('json')
expect.soft(r.body).toMatchObject({ ok: true })
})
デバッグ時、1つ直したらまた次の失敗が出る状況を繰り返さずに済みます。
14. toMatchInlineSnapshot は目視レビューの武器
出力形状の回帰を防ぎたいとき、外部の .snap ファイルを増やすよりインラインのほうがレビューが速いです。
test('renders help', () => {
expect(renderHelp()).toMatchInlineSnapshot(`
"Usage: cli <cmd>
--verbose verbose output
--version print version"
`)
})
vitest -u で更新できますが、スナップショットは差分を人の目で読む前提であって、盲目的に更新するのは危険です。PRのdiffレビュー対象に含めることが前提の道具だと考えてください。
15. --changed と --related で必要なテストだけ走らせる
CIも手元も高速化に効きます。
vitest --changed # Gitで変更があったファイル関連のみ
vitest --changed origin/main # base branchとの差分に絞る
vitest related src/utils.ts # このファイルを参照するテストだけ
大きなリポジトリほど恩恵が大きい機能です。related は依存グラフを解析して該当テストを引っ張ってくるので、影響範囲の可視化としても使えます。
16. vitest --ui はローカル最強のデバッグ環境
@vitest/ui を入れて起動するだけで、ブラウザベースのテストダッシュボードが立ち上がります。
npm i -D @vitest/ui
vitest --ui
- 個別テストの再実行、フィルタ、ソースコード表示
- モジュールグラフの可視化
- カバレッジのビジュアル表示
console.log を仕込んで再実行のループを回すより、UIから該当テストだけを叩けるほうがはるかに速いです。
17. --inspect-brk でデバッガに落とす
Node系のテストで詰まったら、単純にデバッガに入ります。
vitest --inspect-brk --no-file-parallelism -t 'name of test'
--no-file-parallelism を付けないとブレークポイントが安定しません。VS Codeなら .vscode/launch.json からAttachするだけで、ブレークポイント・ステップ実行・変数ウォッチが揃います。
18. bail と --reporter で止めどきと見やすさを制御
-
--bail=1: 1件落ちたら止める(原因1件目に集中) -
--reporter=verbose: 何が走ったかを詳細に出力 -
--reporter=dot: CIログを短くする -
--reporter=json --outputFile=...: 機械可読な形で保存
CIの遅さの体感は、実行時間そのものより読みにくいログで発生していることが多いです。ここは軽視しないほうが幸せになれます。
19. カバレッジは目標ではなく落とす閾値
@vitest/coverage-v8 を入れて、閾値割れでCIが落ちる設定にして初めて意味が出ます。
// vitest.config.ts
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
thresholds: {
lines: 85, functions: 85, branches: 80, statements: 85,
},
exclude: ['**/*.d.ts', 'src/generated/**'],
},
}
100%を目標にするとテストのためのテストが増えていくので、下限を引いて、それを割ったら止まる設計にとどめます。
20. setupFiles にプロジェクト共通のふるまいを寄せる
最後はTipsというより設計の話です。以下のようなものは各テストに書かず setupFiles に寄せます。
- タイムゾーン・ロケール・乱数シードの固定
-
fetchなどグローバルAPIのデフォルト遮断(MSW/undici の interceptor で opt-in で解禁) -
console.errorをテスト失敗に昇格させる(React警告の見逃し防止)
// test/setup.ts
import { beforeEach, vi, expect } from 'vitest'
beforeEach(() => {
vi.useFakeTimers({ now: new Date('2026-08-07T00:00:00Z') })
process.env.TZ = 'UTC'
})
const origError = console.error
console.error = (...args: unknown[]) => {
origError(...args)
throw new Error('console.error called during test: ' + args.join(' '))
}
テストは決定的であるという一番大事な性質を、こうしたグローバルsetupが下から支えてくれます。
おわりに — テストは書けるより回せるが価値
Vitestは「Jest互換の書き味 × Viteの速度」という一次的な魅力に注目されがちですが、実務で効いてくる本当の価値は次の2点です。
-
失敗の原因に、より早くたどり着けること(
--changed,--ui,test.each,expect.soft,--inspect-brk) -
テストが将来の変更を邪魔しない構造にできること(environment の局所化、
vi.mockの境界設計、setupFiles による副作用の局所化)
ツールとしてのVitestはここに書いた程度のことを覚えれば十分実務で戦えます。あとは実際に自分のコードで手を動かして書いてみることでしか身につかない部分です。
手を動かして学びたい方へ
もしこの記事で触れたようなテスト設計や JavaScript/TypeScript の実務スキルを、読むだけでなく実際に手を動かして身につけたいという方向けに、自分の Udemy 講座を割引で購入できるリンクを置いておきます。
すべての講座が演習型になっており、動画を眺めるだけではなく、その場でコードを書きながら理解を進める構成にしています。無料プレビューだけでも覗いてみてもらえると嬉しいです。