Vitest 5 系(Node.js 22.12+ / Vite 6.4+)を前提に、TypeScript プロジェクトでテストを書く・運用するときの指針をまとめる。
目次
- 1. セットアップ
- 2. ファイル構成と命名
- 3. テストの書き方
- 4. モック
- 5. テストデータとフィクスチャ
- 6. 複数環境の管理(projects)
- 7. 型のテスト
- 8. スナップショット
- 9. テストの独立性と安定性
- 10. パフォーマンス
- 11. カバレッジ
- 12. CI 運用
- 13. Lint
- 14. Vitest 5 へ移行する際のチェックリスト
- 参考
1. セットアップ
1.1 インストール
npm i -D vitest @vitest/coverage-v8
# UI が欲しければ
npm i -D @vitest/ui1.2 設定ファイル
設定は TypeScript で書き、defineConfig で型補完を効かせる。Vite を使っているプロジェクトなら vite.config.ts に同居させてもよいが、テスト専用の設定が膨らむなら vitest.config.ts に分ける。
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['src/**/*.{test,spec}.ts'],
environment: 'node', // DOM が必要なテストだけ jsdom / happy-dom に
setupFiles: ['./test/setup.ts'],
restoreMocks: true, // spyOn の元実装を毎テスト後に戻す
unstubEnvs: true, // vi.stubEnv を自動で戻す
unstubGlobals: true, // vi.stubGlobal を自動で戻す
coverage: {
provider: 'v8',
include: ['src/**/*.ts'],
exclude: ['src/**/*.d.ts', 'src/**/index.ts'],
thresholds: { lines: 80, branches: 75 },
},
},
})ポイント:
globals: trueは基本的に使わない。import { describe, it, expect, vi } from 'vitest'と明示的に import した方が、どこから来た関数か明確で、tsconfigに型を足す必要もない。Jest からの移行で既存コードが大量にある場合のみglobals: true+"types": ["vitest/globals"]を検討する。- Vitest 5 では
clearMocksがデフォルトで有効になった。呼び出し履歴は毎テスト前に消えるが、実装は残る。実装まで戻したいならrestoreMocks: trueを併用する。
1.3 tsconfig
// tsconfig.json(抜粋)
{
"compilerOptions": {
"strict": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
},
"include": ["src", "test", "vitest.config.ts"]
}- Vitest は esbuild/oxc でトランスパイルするだけで型チェックはしない。CI では別途
tsc --noEmitを走らせるか、後述の型テスト(--typecheck)を使う。 - パスエイリアスは
tsconfigのpathsと Vite のresolve.aliasを二重管理せず、vite-tsconfig-pathsプラグインか Vite のresolve.tsconfigPaths相当の機能で一元化する。
1.4 npm scripts
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:cov": "vitest run --coverage",
"test:type": "vitest run --typecheck.only",
"typecheck": "tsc --noEmit"
}
}ローカルは watch モード(vitest)、CI は vitest run と使い分ける。
2. ファイル構成と命名
- テストは対象ファイルの隣に置く(
user.tsとuser.test.ts)。移動・削除時に追従しやすい。 - 統合テストや E2E 寄りのテストは
test/やtests/integration/に分け、projectsで設定を分離する(6章)。 - 共通のフィクスチャ・ファクトリは
test/fixtures/、test/factories/にまとめる。 - テスト名は「何をすると、どうなるか」を日本語でも英語でもよいので文章で書く。
describe('calculateTotal', () => {
it('割引コードが期限切れなら割引を適用しない', () => { /* ... */ })
})3. テストの書き方
3.1 AAA(Arrange / Act / Assert)
1 テスト 1 振る舞い。準備・実行・検証を空行で分けると読みやすい。
import { describe, it, expect } from 'vitest'
import { applyDiscount } from './pricing'
it('10% 割引を適用する', () => {
const price = 1000
const result = applyDiscount(price, 0.1)
expect(result).toBe(900)
})3.2 実装ではなく振る舞いをテストする
- private 関数や内部状態を直接検証しない。公開 API の入出力で確認する。
- 「この関数が 3 回呼ばれた」より「結果がこうなった」を優先する。呼び出し検証は副作用(API 送信、ログ出力など)が仕様そのものである場合に限定する。
3.3 パラメタライズドテスト
境界値や組み合わせは it.each / it.for でまとめる。オブジェクト形式にすると型推論が効き、テスト名にも値を埋め込める。
it.each([
{ input: 0, expected: 'zero' },
{ input: 1, expected: 'positive' },
{ input: -1, expected: 'negative' },
])('classify($input) は $expected を返す', ({ input, expected }) => {
expect(classify(input)).toBe(expected)
})it.for はテストコンテキスト(expect など)を第 2 引数で受け取れるので、concurrent と組み合わせるときに便利。
3.4 適切なマッチャーを選ぶ
| やりたいこと | 推奨 | 避けたい |
|---|---|---|
| プリミティブの一致 | toBe | toEqual |
| オブジェクト/配列の構造一致 | toEqual | toBe |
undefined プロパティやクラスも厳密に | toStrictEqual | toEqual |
| 一部のプロパティだけ確認 | toMatchObject / expect.objectContaining | 全体比較 |
| 浮動小数点 | toBeCloseTo | toBe |
| 例外 | toThrow(ErrorClass) / toThrow(/msg/) | try/catch 自前実装 |
Vitest 5 では toThrow('') が「任意のエラー」にマッチするようになった。空メッセージを確認したい場合は /^$/ を使う。
3.5 非同期テスト
- 非同期関数は必ず
async/awaitで書く。 .resolves/.rejectsには必ずawaitを付ける。Vitest 5 では await し忘れた非同期アサーションはテスト失敗になる。
it('存在しないユーザーはエラーになる', async () => {
await expect(fetchUser('unknown')).rejects.toThrow(NotFoundError)
})- 「最終的にこうなる」を待つ場合は
expect.pollやvi.waitForを使い、setTimeoutで固定時間待たない。Vitest 5 ではexpect.pollはタイムアウト時に失敗する。
3.6 expect.assertions / expect.hasAssertions
コールバックやイベントの中でアサーションする場合、アサーションが実行されなかったことを検出できるよう宣言しておく。
it('イベントが発火する', async () => {
expect.assertions(1)
emitter.on('done', (v) => expect(v).toBe(42))
await run()
})4. モック
4.1 基本方針
- モックは境界(ネットワーク、DB、ファイルシステム、時刻、乱数)に限定する。自分のドメインロジックはなるべく本物を使う。
- 可能なら依存性注入(引数やコンストラクタで依存を受け取る)にして、
vi.mockに頼らずテストダブルを渡せる設計にする。モジュールモックは最後の手段。
4.2 vi.fn に型を付ける
import { vi, type Mock } from 'vitest'
type Fetcher = (id: string) => Promise<User>
const fetcher = vi.fn<Fetcher>()
fetcher.mockResolvedValue({ id: '1', name: 'Ada' })型パラメータに関数型を渡すと、mockResolvedValue などの引数も型チェックされる。
4.3 vi.spyOn
既存オブジェクトのメソッドを一時的に差し替える場合に使う。restoreMocks: true を設定しておけば各テスト後に自動で元に戻る。
const spy = vi.spyOn(logger, 'warn').mockImplementation(() => {})
doSomething()
expect(spy).toHaveBeenCalledWith(expect.stringContaining('deprecated'))4.4 vi.mock(モジュールモック)
vi.mockはファイル先頭に巻き上げ(hoist)られる。describeなどのブロック内に書かない(Vitest 5 ではエラーになる)。- ファクトリ内で使う変数は
vi.hoistedで定義する。
import { vi, it, expect } from 'vitest'
import { getProfile } from './profile'
const { fetchUser } = vi.hoisted(() => ({ fetchUser: vi.fn() }))
vi.mock('./api', () => ({ fetchUser }))
it('プロフィールを組み立てる', async () => {
fetchUser.mockResolvedValue({ id: '1', name: 'Ada' })
expect(await getProfile('1')).toEqual({ displayName: 'Ada' })
})- 一部だけ差し替えたいときは
importOriginalを使い、型を付ける。
vi.mock('./config', async (importOriginal) => {
const actual = await importOriginal<typeof import('./config')>()
return { ...actual, isFeatureEnabled: vi.fn(() => true) }
})- モック済みモジュールを型安全に扱うには
vi.mocked()を使う。
import { fetchUser } from './api'
vi.mock('./api')
vi.mocked(fetchUser).mockResolvedValue({ id: '1', name: 'Ada' })4.5 引数ごとの戻り値
Vitest 5 では vi.when で引数に応じた戻り値を宣言的に書ける。mockImplementation 内の if 分岐より読みやすい。
vi.when(getUser).calledWith(1).thenResolve({ id: 1, name: 'Ada' })4.6 時刻・タイマー
import { beforeEach, afterEach, vi } from 'vitest'
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2025-01-01T00:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
})
it('5 秒後にリトライする', async () => {
const p = retry(task)
await vi.advanceTimersByTimeAsync(5000)
await expect(p).resolves.toBe('ok')
})- Promise が絡むタイマーは
advanceTimersByTimeAsync/runAllTimersAsyncを使う。 - フェイクタイマーは必ず
afterEachで戻す。
4.7 環境変数・グローバル
process.env.X = ... を直接書き換えず、vi.stubEnv / vi.stubGlobal を使い、設定の unstubEnvs / unstubGlobals で自動復元する。
4.8 HTTP のモック
fetch を直接 vi.fn で差し替えるより、MSW でネットワーク層をモックする方が実装の詳細に依存せず、本番コードの変更に強い。
// test/setup.ts
import { setupServer } from 'msw/node'
import { beforeAll, afterAll, afterEach } from 'vitest'
import { handlers } from './handlers'
export const server = setupServer(...handlers)
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())5. テストデータとフィクスチャ
5.1 ファクトリ関数
テストごとに巨大なオブジェクトリテラルを書かず、デフォルト値 + 上書きのファクトリを用意する。
// test/factories/user.ts
import type { User } from '../../src/types'
export const buildUser = (overrides: Partial<User> = {}): User => ({
id: 'user-1',
name: 'Test User',
email: 'test@example.com',
role: 'member',
...overrides,
})テストでは「そのテストにとって意味のある値」だけを明示する。
const admin = buildUser({ role: 'admin' })5.2 test.extend によるフィクスチャ
セットアップ・後片付けが必要なリソース(DB 接続、一時ディレクトリなど)は test.extend で型付きフィクスチャにする。使ったテストでだけ初期化されるので無駄がない。
// test/fixtures.ts
import { test as base } from 'vitest'
import { createTestDb, type TestDb } from './db'
export const test = base.extend<{ db: TestDb }>({
db: async ({}, use) => {
const db = await createTestDb()
await use(db)
await db.destroy()
},
})import { expect } from 'vitest'
import { test } from '../test/fixtures'
test('ユーザーを保存できる', async ({ db }) => {
await db.users.insert(buildUser())
expect(await db.users.count()).toBe(1)
})beforeEach で変数に代入するパターンよりも、型推論と依存関係が明確になる。
6. 複数環境の管理(projects)
ユニットテスト(node)とコンポーネントテスト(DOM / ブラウザ)など、環境が異なるテストは projects で分ける。
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
projects: [
{
extends: true,
test: {
name: 'unit',
include: ['src/**/*.test.ts'],
environment: 'node',
},
},
{
extends: true,
test: {
name: 'dom',
include: ['src/**/*.test.tsx'],
environment: 'happy-dom',
},
},
],
},
})- 1 ファイルだけ環境を変えたい場合は、ファイル先頭の
// @vitest-environment jsdomコメントでもよい。 - jsdom / happy-dom は「それっぽい DOM」でしかない。レイアウトやブラウザ API の挙動が重要なら Browser Mode(
@vitest/browser+ Playwright)を検討する。 - テストを横断的に分類したい(slow / integration など)場合はテストタグ(4.1 以降)を使い、CLI でフィルタする。
7. 型のテスト
ライブラリや汎用ユーティリティでは、型そのものも仕様。expectTypeOf で型をテストする。
// src/utils.test-d.ts
import { expectTypeOf, test } from 'vitest'
import { pick } from './utils'
test('pick は指定キーだけの型を返す', () => {
const result = pick({ a: 1, b: 'x', c: true }, ['a', 'b'])
expectTypeOf(result).toEqualTypeOf<{ a: number; b: string }>()
})// vitest.config.ts
test: {
typecheck: {
enabled: true,
include: ['src/**/*.test-d.ts'],
},
}*.test-d.tsは実行されず、tscによる静的解析のみ行われる。// @ts-expect-errorと組み合わせて「コンパイルエラーになるべき」ケースも書ける。
8. スナップショット
- 大きなスナップショットは差分レビューが形骸化しやすい。小さく、意図が明確なものに限る。
- 短いものは
toMatchInlineSnapshotでテスト内に置くと、レビュー時に期待値が見える。 - 日時や ID など変動する値は
expect.any(String)などのプロパティマッチャーで固定する。
expect(createOrder()).toMatchInlineSnapshot(
{ id: expect.any(String), createdAt: expect.any(Date) },
`...`,
)-uで一括更新する前に、差分が意図したものか必ず確認する。
9. テストの独立性と安定性
- テスト同士で状態を共有しない。順序を入れ替えても通ること。
--sequence.shuffleで定期的に確認するとよい。 - モジュールレベルの可変状態(シングルトン、キャッシュ)は
beforeEachでリセットする関数を用意するか、vi.resetModules()で再読み込みする。 it.only/describe.onlyのコミットを防ぐため、CI では--allowOnly=false(CI 環境ではデフォルト)を維持し、ESLint でも検出する。it.skipは理由と期限をコメントで残す。it.todoで未実装のテストを明示する。- 不安定なテストを
retryで隠さない。リトライは原因調査までの暫定措置にとどめる。
10. パフォーマンス
- DOM 環境は必要なテストだけに。
environment: 'jsdom'を全体に設定すると大幅に遅くなる。 - 重い初期化は
globalSetup(全体で 1 回)に移し、setupFiles(ファイルごと)を軽く保つ。 - バレルファイル(
index.tsで大量 re-export)経由の import はモジュールグラフを膨らませる。テストからは実体ファイルを直接 import する。 - プール設定: デフォルトの
forksは安定性重視。ネイティブモジュールを使わず速度を優先したいならthreadsを試す。isolate: falseはさらに速いが、テスト間の状態漏れに注意。 - 独立した I/O 待ちが多いテストは
describe.concurrentで並行化できる。その場合はコンテキストのexpectを使う。
describe.concurrent('外部 API', () => {
it('A', async ({ expect }) => { /* ... */ })
it('B', async ({ expect }) => { /* ... */ })
})- 大規模なテストスイートは CI で
--shard=1/4のように分割し、--merge-reportsで結果を集約する。
11. カバレッジ
provider: 'v8'を基本とする(高速で、4.x 以降は AST ベースのリマッピングにより精度も十分)。coverage.includeを明示し、テストされていないファイルも分母に入れる。- しきい値(
thresholds)は現状値付近から始めて段階的に引き上げる。100% を目標にしない。 - カバレッジは「テストされていない箇所の発見」に使う指標であり、テストの質を保証するものではない。
12. CI 運用
# .github/workflows/test.yml(抜粋)
- run: npm ci
- run: npm run typecheck
- run: npx vitest run --coverage
- uses: actions/upload-artifact@v4
if: failure()
with:
name: vitest-output
path: .vitest/- 型チェックとテストは別ステップにして、失敗原因を切り分けやすくする。
- Vitest 5 ではレポーター出力(blob、attachments、JSON / JUnit / HTML)がデフォルトで
.vitest/配下にまとまる。成果物アップロードのパスと.gitignoreを合わせて更新する。 - GitHub Actions では組み込みの
github-actionsレポーターで失敗箇所が PR 上にアノテーションされる。
13. Lint
@vitest/eslint-plugin を導入し、少なくとも以下を有効にする。
vitest/no-focused-tests—.onlyの混入防止vitest/no-disabled-tests—.skipの放置検出vitest/expect-expect— アサーションのないテストの検出vitest/valid-expect—await漏れなど誤ったexpectの検出vitest/no-identical-title— 重複したテスト名の検出
14. Vitest 5 へ移行する際のチェックリスト
- Node.js 22.12.0 以上、Vite 6.4.0 以上になっているか
-
beforeAllやセットアップファイルで記録したvi.fnの呼び出し履歴を、テスト内で検証していないか(clearMocksデフォルト有効化の影響) -
describeなどのブロック内にvi.mockを書いていないか(vi.hoisted+ トップレベルへ移動) -
.resolves/.rejectsにawaitが付いているか -
toThrow('')を「空メッセージ」の意味で使っていないか -
expect.pollのタイムアウトに依存したテストがないか - CI の成果物パス・
.gitignoreを.vitest/に合わせたか - 公式の移行ガイドで、その他の破壊的変更が自分のプロジェクトに該当しないか確認したか
参考
- Vitest 公式ドキュメント: https://vitest.dev/
- Vitest 5 移行ガイド: https://vitest.dev/guide/migration
- MSW: https://mswjs.io/
- @vitest/eslint-plugin: https://github.com/vitest-dev/eslint-plugin-vitest