Vitest 5 系(Node.js 22.12+ / Vite 6.4+)を前提に、TypeScript プロジェクトでテストを書く・運用するときの指針をまとめる。


目次


1. セットアップ

1.1 インストール

npm i -D vitest @vitest/coverage-v8
# UI が欲しければ
npm i -D @vitest/ui

1.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 適切なマッチャーを選ぶ

やりたいこと推奨避けたい
プリミティブの一致toBetoEqual
オブジェクト/配列の構造一致toEqualtoBe
undefined プロパティやクラスも厳密にtoStrictEqualtoEqual
一部のプロパティだけ確認toMatchObject / expect.objectContaining全体比較
浮動小数点toBeCloseTotoBe
例外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/ に合わせたか
  • 公式の移行ガイドで、その他の破壊的変更が自分のプロジェクトに該当しないか確認したか

参考

0件のページ