※本記事には広告(楽天アフィリエイト)リンクが含まれる場合があります。

「Jestの設定が重すぎる」「TypeScriptプロジェクトでトランスパイルに時間がかかる」「Viteを使っているのにテストだけ別の設定が必要で面倒」——こうした悩みを解消するために生まれたのが Vitest です。

VitestはViteが提供するネイティブESMサポートと高速トランスパイルをそのままテストに活かした、次世代ユニットテストフレームワークです。2023年以降急速に採用が広がり、2026年現在はViteベースのプロジェクト(Astro・SvelteKit・Nuxt・Remix等)でほぼデファクトの選択肢となっています。設定ファイルをViteと共有でき、起動が爆速で、JestのAPIと高い互換性を持つため移行も容易です。

本記事では、インストールから基本的なテスト構文、モック・スパイ、カバレッジ計測、React/Vueコンポーネントテスト、CI設定、Jestからの移行まで、個人開発ですぐ使い始めるための実例を交えて解説します。

広告・楽天アフィリエイト

プログラミング技術書

実践寄りの技術書・参考書を探す。

楽天ポイント還元

技術書を楽天で見る →

Vitestとは何か?なぜ今選ぶべきか

VitestはViteのコアチームが開発しているテストフレームワークです。内部的にViteの変換パイプラインを使うため、vite.config.ts に書いた設定(エイリアス・プラグイン・環境変数等)をそのままテスト環境でも利用できます。

Jestと比べたときの主なメリットは以下の通りです。

項目 Vitest Jest
起動速度 非常に速い(HMR再利用) 比較的遅い
TypeScript対応 設定不要でそのまま動く ts-jest / @swc/jestが必要
ESMサポート ネイティブ対応 実験的(設定が煩雑)
設定ファイル vite.config.tsと共有可 jest.config.ts が別途必要
Jestとの互換性 API互換(移行しやすい)
ウォッチモード インタラクティブUI付き あり
並列実行 ワーカースレッド・forks ワーカープロセス

唯一の注意点はNode.jsプロジェクト専用ではなくViteエコシステムに寄せた設計であることです。ExpressやNestJSなどサーバーサイドのみのプロジェクトでも利用できますが、ViteをビルドツールとしてすでにViteを使っているプロジェクトで最大の恩恵を得られます。

インストールと初期設定

既存のViteプロジェクトへの追加は1コマンドで完了します。

npm install -D vitest
# または
pnpm add -D vitest

vite.config.ts(または新規に作成する vitest.config.ts)に test セクションを追加します。

// vite.config.ts
import { defineConfig } from 'vite'

export default defineConfig({
  test: {
    // グローバルAPI(describe/it/expectを import なしで使える)
    globals: true,
    // DOM環境のシミュレーション(Reactテストに必要)
    environment: 'jsdom',
    // テスト前に実行するセットアップファイル
    setupFiles: './src/test/setup.ts',
  },
})

package.json にスクリプトを追加します。

{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage"
  }
}

globals: true を使う場合は、TypeScriptの型定義も追加します。

// tsconfig.json
{
  "compilerOptions": {
    "types": ["vitest/globals"]
  }
}

基本的なテストの書き方

VitestのAPIはJestとほぼ同じです。describeit(または test)・expect を使ってテストを書きます。

// src/utils/math.ts
export function add(a: number, b: number): number {
  return a + b
}

export function divide(a: number, b: number): number {
  if (b === 0) throw new Error('Division by zero')
  return a / b
}
// src/utils/math.test.ts
import { describe, it, expect } from 'vitest'
import { add, divide } from './math'

describe('add', () => {
  it('2つの数を足す', () => {
    expect(add(1, 2)).toBe(3)
  })

  it('負の数にも対応', () => {
    expect(add(-1, 1)).toBe(0)
  })
})

describe('divide', () => {
  it('正しく割り算できる', () => {
    expect(divide(10, 2)).toBe(5)
  })

  it('ゼロ除算でエラーを投げる', () => {
    expect(() => divide(10, 0)).toThrow('Division by zero')
  })
})

よく使うマッチャーをまとめます。

マッチャー 用途
toBe(value) プリミティブ値の厳密一致(===
toEqual(value) オブジェクト・配列の深い一致
toStrictEqual(value) undefinedプロパティも含めた厳密な深い一致
toBeNull() / toBeUndefined() null / undefined チェック
toBeTruthy() / toBeFalsy() truthy / falsy チェック
toContain(item) 配列・文字列に含まれるかチェック
toThrow(message?) 例外を投げることをチェック
toMatchSnapshot() スナップショットテスト
resolves / rejects Promise の成功・失敗チェック

非同期テスト

async/await を使った非同期テストも自然に書けます。

// src/api/user.ts
export async function fetchUser(id: number) {
  const res = await fetch(`/api/users/${id}`)
  if (!res.ok) throw new Error('User not found')
  return res.json()
}
// src/api/user.test.ts
import { it, expect, vi } from 'vitest'
import { fetchUser } from './user'

it('ユーザーを取得できる', async () => {
  // fetch をモック(後述)
  vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
    ok: true,
    json: async () => ({ id: 1, name: 'Alice' }),
  }))

  const user = await fetchUser(1)
  expect(user).toEqual({ id: 1, name: 'Alice' })
})

it('404のときエラーを投げる', async () => {
  vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))

  await expect(fetchUser(999)).rejects.toThrow('User not found')
})

モック・スパイの使い方

Vitestのモック機能は vi オブジェクト経由で使います。Jestの jest オブジェクトと対応関係にあります。

vi.fn() — 関数モック

import { it, expect, vi } from 'vitest'

it('コールバックが呼ばれる', () => {
  const callback = vi.fn()

  [1, 2, 3].forEach(callback)

  expect(callback).toHaveBeenCalledTimes(3)
  expect(callback).toHaveBeenCalledWith(1, 0, [1, 2, 3])
})

vi.mock() — モジュールモック

モジュール全体をモックするには vi.mock() をファイルの先頭付近に置きます。巻き上げ(hoisting)によりimportより前に実行されます。

import { it, expect, vi } from 'vitest'
import { sendEmail } from './mailer'
import { registerUser } from './user-service'

vi.mock('./mailer', () => ({
  sendEmail: vi.fn().mockResolvedValue({ success: true }),
}))

it('ユーザー登録時にメールが送信される', async () => {
  await registerUser({ email: 'alice@example.com', name: 'Alice' })

  expect(sendEmail).toHaveBeenCalledOnce()
  expect(sendEmail).toHaveBeenCalledWith(
    expect.objectContaining({ to: 'alice@example.com' })
  )
})

vi.spyOn() — スパイ

実装を残しつつ呼び出しを監視するにはスパイを使います。

import { it, expect, vi, afterEach } from 'vitest'

afterEach(() => vi.restoreAllMocks())

it('console.error が呼ばれる', () => {
  const spy = vi.spyOn(console, 'error').mockImplementation(() => {})

  // エラーをトリガーする処理
  someFunction()

  expect(spy).toHaveBeenCalledOnce()
})

vi.useFakeTimers() — タイマーモック

import { it, expect, vi, beforeEach, afterEach } from 'vitest'
import { debounce } from './utils'

beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())

it('デバウンス後に1回だけ呼ばれる', () => {
  const fn = vi.fn()
  const debouncedFn = debounce(fn, 300)

  debouncedFn()
  debouncedFn()
  debouncedFn()

  expect(fn).not.toHaveBeenCalled()

  vi.advanceTimersByTime(300)

  expect(fn).toHaveBeenCalledOnce()
})

コンポーネントテスト(React)

Reactコンポーネントのテストには @testing-library/reactjsdom 環境を組み合わせます。

npm install -D @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom

セットアップファイルでjest-domのマッチャーをインポートします。

// src/test/setup.ts
import '@testing-library/jest-dom'
// src/components/Counter.tsx
import { useState } from 'react'

export function Counter() {
  const [count, setCount] = useState(0)
  return (
    <div>
      <p data-testid="count">{count}</p>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
      <button onClick={() => setCount(c => c - 1)}>-1</button>
    </div>
  )
}
// src/components/Counter.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Counter } from './Counter'

it('初期値は0', () => {
  render(<Counter />)
  expect(screen.getByTestId('count')).toHaveTextContent('0')
})

it('+1ボタンで増える', async () => {
  const user = userEvent.setup()
  render(<Counter />)

  await user.click(screen.getByRole('button', { name: '+1' }))

  expect(screen.getByTestId('count')).toHaveTextContent('1')
})

it('複数回クリックできる', async () => {
  const user = userEvent.setup()
  render(<Counter />)

  await user.click(screen.getByRole('button', { name: '+1' }))
  await user.click(screen.getByRole('button', { name: '+1' }))
  await user.click(screen.getByRole('button', { name: '-1' }))

  expect(screen.getByTestId('count')).toHaveTextContent('1')
})

カバレッジ計測

Vitestはc8(V8のネイティブカバレッジ)とistanbul(ソース計装)の2つのプロバイダに対応しています。個人開発では設定が不要な @vitest/coverage-v8 が手軽です。

npm install -D @vitest/coverage-v8
// vite.config.ts
export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      // カバレッジ対象から除外するパターン
      exclude: ['**/node_modules/**', '**/test/**', '**/*.d.ts'],
      // 最低カバレッジ閾値(CIで使う)
      thresholds: {
        statements: 80,
        branches: 70,
        functions: 80,
        lines: 80,
      },
    },
  },
})
npx vitest run --coverage

実行後 coverage/index.html が生成され、ブラウザでカバレッジレポートを確認できます。

ウォッチモードとインタラクティブUI

npx vitest を引数なしで実行するとウォッチモードになります。ファイル変更を検知して関連テストだけを再実行するため、TDDサイクルが非常に快適です。

さらに --ui フラグを使うとブラウザ上のGUIでテスト結果を確認できます。

npm install -D @vitest/ui
npx vitest --ui

GUIでは:テストの一覧表示、個別テストの再実行、カバレッジの可視化、コードへの直接ジャンプが可能です。チーム開発でレビューするときにも役立ちます。

並列実行と分離モード

Vitestはデフォルトでテストファイルをワーカースレッドで並列実行します。設定で粒度を調整できます。

// vite.config.ts
export default defineConfig({
  test: {
    // ワーカースレッド数(デフォルトはCPUコア数に基づく)
    pool: 'threads',
    poolOptions: {
      threads: {
        maxThreads: 4,
        minThreads: 1,
      },
    },
    // ファイル内のテストを並列実行(注意: 副作用があるテストに向かない)
    // concurrent: true,

    // メモリを節約するためワーカーを使い捨て
    isolate: true,
  },
})

DBアクセスなど副作用があるテストでは --pool=forks(プロセス分離)を使うと安全です。

環境ごとのテスト設定(jsdom / happy-dom / node)

Vitestでは環境をファイルごとに切り替えられます。

// @vitest-environment jsdom
// ファイル先頭のコメントで環境を指定

import { it, expect } from 'vitest'

it('DOMを操作できる', () => {
  document.body.innerHTML = '<div id="app"></div>'
  expect(document.getElementById('app')).not.toBeNull()
})

または設定ファイルで environmentMatchGlobs を使い、パターンで自動切り替えできます。

// vite.config.ts
export default defineConfig({
  test: {
    environmentMatchGlobs: [
      ['**/*.component.test.tsx', 'jsdom'],
      ['**/*.api.test.ts', 'node'],
    ],
  },
})

CI(GitHub Actions)設定

Vitestのレポーターを使うと、GitHub ActionsのPR上に直接テスト結果とカバレッジを表示できます。

# .github/workflows/test.yml
name: Test

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'npm'

      - run: npm ci

      - name: Run tests with coverage
        run: npx vitest run --coverage --reporter=verbose --reporter=github-actions

      - name: Upload coverage report
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: coverage
          path: coverage/

--reporter=github-actions を使うと失敗したテストがPR上のアノテーションとして表示されます。

Jestからの移行

既存のJestプロジェクトからVitestへの移行は、多くの場合わずかな変更で完了します。

ステップ1: パッケージの入れ替え

# Jestを削除
npm uninstall jest @types/jest ts-jest babel-jest jest-environment-jsdom

# Vitestをインストール
npm install -D vitest @vitest/coverage-v8

ステップ2: import の置き換え

テストファイルで from '@jest/globals' などJest固有のimportを使っている場合のみ変更が必要です。globals: true にすれば import 自体が不要になります。

// Before
import { describe, it, expect, jest } from '@jest/globals'

// After
import { describe, it, expect, vi } from 'vitest'
// または globals: true なら import 不要

ステップ3: jest → vi への置き換え

# 一括置換(sedの例)
sed -i 's/jest\.fn/vi.fn/g' src/**/*.test.ts
sed -i 's/jest\.mock/vi.mock/g' src/**/*.test.ts
sed -i 's/jest\.spyOn/vi.spyOn/g' src/**/*.test.ts
sed -i 's/jest\.clearAllMocks/vi.clearAllMocks/g' src/**/*.test.ts

ステップ4: jest.config.ts の削除と vite.config.ts への統合

jest.config.ts を削除し、vite.config.ts の test セクションに設定を移動します。moduleNameMapper(エイリアス設定)は vite.config.ts の resolve.alias がそのまま引き継がれるため、多くの場合追加設定は不要です。

実践パターン:APIルートのテスト(Hono)

HonoアプリのAPIテストをVitestで書く例です。app.request() を使うとHTTPクライアントを立てずにテストできます。

// src/routes/user.ts
import { Hono } from 'hono'

export const userRouter = new Hono()
  .get('/:id', async (c) => {
    const id = c.req.param('id')
    // DB取得(省略)
    return c.json({ id, name: 'Alice' })
  })
  .post('/', async (c) => {
    const body = await c.req.json()
    if (!body.name) return c.json({ error: 'name is required' }, 400)
    return c.json({ id: '1', ...body }, 201)
  })
// src/routes/user.test.ts
import { describe, it, expect } from 'vitest'
import { userRouter } from './user'

describe('GET /user/:id', () => {
  it('ユーザーを返す', async () => {
    const res = await userRouter.request('/1')
    expect(res.status).toBe(200)
    const body = await res.json()
    expect(body).toMatchObject({ id: '1', name: 'Alice' })
  })
})

describe('POST /user', () => {
  it('新規ユーザーを作成', async () => {
    const res = await userRouter.request('/', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name: 'Bob' }),
    })
    expect(res.status).toBe(201)
  })

  it('nameがないと400', async () => {
    const res = await userRouter.request('/', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({}),
    })
    expect(res.status).toBe(400)
  })
})

スナップショットテスト

UIコンポーネントのレンダリング結果を固定しておきたい場合はスナップショットテストが使えます。

import { render } from '@testing-library/react'
import { it, expect } from 'vitest'
import { Badge } from './Badge'

it('マッチスナップショット', () => {
  const { container } = render(<Badge label="NEW" color="green" />)
  expect(container).toMatchSnapshot()
})

初回実行時に __snapshots__/ にスナップショットファイルが生成されます。意図的に変更する場合は npx vitest -u で更新できます。

ただしスナップショットテストはUIの細かい変更のたびに壊れやすいため、ロジックのテストとうまく使い分けることが大切です。

よくあるつまずきポイント

ESMモジュールのモックがうまくいかないvi.mock() は巻き上げられますが、モックファクトリ内で外部変数を参照する場合は vi.hoisted() を使う必要があります。

const mockFn = vi.hoisted(() => vi.fn())

vi.mock('./module', () => ({
  someFunction: mockFn,
}))

グローバルAPIが型エラーになるglobals: truetsconfig.jsontypes: ["vitest/globals"] の両方を設定する必要があります。

jsdom環境でResizeObserverがない:jsdomにはブラウザAPIが一部欠けています。不足するAPIはセットアップファイルでポリフィルします。

// src/test/setup.ts
global.ResizeObserver = class ResizeObserver {
  observe() {}
  unobserve() {}
  disconnect() {}
}

まとめ:Vitestを選ぶ理由

VitestはViteエコシステムにいるなら「Jestの代替として設定コストを下げながら同等以上の機能を使える」最良の選択肢です。特に以下の状況に強くフィットします。

  • Viteを使っているフロントエンドプロジェクト(React・Vue・Svelte等)
  • TypeScriptのみで書かれており、追加のトランスパイル設定を避けたい
  • ESMネイティブのライブラリを多く使っている
  • Jestからの移行コストを最小限にしたい
  • テストサイクルを高速化してTDDを実践したい

逆にJestが依然有力な場面は、CRAやWebpackベースの既存プロジェクトや、NestJSなどJestとの統合が深いフレームワークを使っている場合です。新規プロジェクトでViteを採用するなら、最初からVitestを選択するのがベストプラクティスといえます。

まずは小さなユーティリティ関数のテストから始めて、徐々にコンポーネントテスト・統合テストへと広げていくのがおすすめです。テストがある安心感はコードのリファクタリングを劇的に楽にしてくれます。