言語を切り替える
テーマを切り替える

Vitest 単体テスト実践:TDD ワークフローとカバレッジ設定

ターミナルに Test Suites: 1 failed, 47 passed と表示されている。1 行コードを直し、28 秒待ってテスト完了。また 1 行直すと、また 28 秒。

Jest から Vitest へ移行した人なら、この光景は馴染み深いでしょう。

当時、プロジェクトには約 500 件のテストがあり、npm test のたびに Hacker News を 2 ページ分読める時間がかかっていました。Vitest に切り替えてからは、同じテストが 3 秒強で終わるようになりました。

本記事では 2 つを扱います。Vitest でこの高速なテスト体験をどう実現するか、そして TDD(テスト駆動開発)でテストを書く負担をどう減らすか。価格フォーマット関数を題材に Red-Green-Refactor を一通り体験し、カバレッジ設定、Mock のコツ、Vitest UI というデバッグツールまで触れます。

なぜ Vitest + TDD なのか

まず数字から。

5 万テスト
Vitest 3 秒 vs Jest 28〜34 秒

SitePoint の 2026 年比較では、5 万件のテストで Vitest は 3 秒、Jest は 28〜34 秒。少しの差ではなく、桁が違います。

速度だけが理由ではありません。Jest で ESM モジュールを扱った経験があれば、babel や transformer、jest.config.js の魔法文字列に祈る日々を思い出すでしょう。Vitest は ESM をネイティブサポートし、追加のトランスパイル設定は不要。書いたコードのままテストが走ります。

Vite ユーザーにはもう一つメリットがあります。Vitest は Vite 設定をそのまま使います。vite.config.ts のエイリアス、環境変数、プラグインがテスト環境にも引き継がれ、Jest の moduleNameMapper を二重管理する必要がありません。初めて気づいたとき、テスト設定がここまで楽でいいのかと少し呆れました。

ツールの話の次は手法。TDD は聞いたことはあっても続けている人は少ない。核心は Red-Green-Refactor:失敗するテスト(赤)→ 通る最小コード(緑)→ リファクタリング。直感に反するように感じるかもしれません。テストを先に?

利点は、書くコードの一行一行がテスト通過のため、ということ。余計なロジックや「念のため」の分岐が入りにくい。テスト先行で、関数の役割・戻り値・境界を先に考えざるを得ない。その制約が設計をはっきりさせます。

Vitest の watch モードはこのループと相性が良い。保存すればテストが即走り、結果がターミナルに出る。ウィンドウ切り替えも手動実行も不要。副操縦士が常に横で「ここ直したら落ちた」「今は全部緑」と教えてくれるイメージ。即時フィードバックが、自然とフロー状態へ導きます。

TDD 実践:関数をゼロから作る

話だけでは足りないので、TDD で formatPrice() を作ります。数値を通貨表示に変換する関数で、1234.5¥1,234.50 にします。

Red フェーズ:失敗するテストを先に書く

プロジェクトで formatPrice.test.ts を新規作成:

// formatPrice.test.ts
import { describe, it, expect } from 'vitest'
import { formatPrice } from './formatPrice'

describe('formatPrice', () => {
  it('数値を人民元の通貨形式にフォーマットする', () => {
    expect(formatPrice(1234.5)).toBe('¥1,234.50')
  })
})

この時点で npx vitest を実行すると、真っ赤なエラー:Cannot find module './formatPrice'。関数がまだないから当然。

これが Red。テストが失敗し、未実装の要件が定義された状態。先にコードを書くより、テストが本当に検証したいことを保証しやすい、というのが TDD の考え方です。

Green フェーズ:通る最小コードを書く

formatPrice.ts を作成し、テストを通すだけのコードを書きます:

// formatPrice.ts
export function formatPrice(value: number): string {
  return '¥1,234.50'  // まずは戻り値をハードコード
}

再実行すると緑。

「ズルじゃないの?」と思うかもしれません。TDD が求めるのは「ちょうど通る」コード。ハードコードでも最小ロジックでも、テストが通れば検証可能な土台ができます。次のテストを足し、コードを少しずつ育てていきます。

もう一つケースを追加:

it('異なる数値も正しく処理する', () => {
  expect(formatPrice(0)).toBe('¥0.00')
  expect(formatPrice(99.99)).toBe('¥99.99')
})

再び赤。ハードコードでは通らないので、本物のロジックが必要:

export function formatPrice(value: number): string {
  return `¥${value.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',')}`
}

実行すれば再び全緑。正規表現は美しくないですが、今は動けば十分。

Refactor フェーズ:構造を最適化

テストが通ったので、安心してリファクタリングできます。壊せばすぐ赤に戻ります。

// リファクタリング後
export function formatPrice(value: number): string {
  // Intl.NumberFormat の方が堅牢
  return new Intl.NumberFormat('zh-CN', {
    style: 'currency',
    currency: 'CNY',
    minimumFractionDigits: 2,
  }).format(value)
}

テストは引き続き全緑。一サイクル完了。

Red-Green-Refactor の一巡です。失敗から始め、最小実装、そして改善。常にテストが守ってくれるので、怖くて手を止めなくていい。各ステップが小さい分、境界ケースを一度に全部考えなくて済み、テストが不足を教えてくれます。

実務では watch モードで回すことが多い。保存 → 自動実行 → 結果確認 → 修正 → 保存、数秒のループ。エディタから離れず、「直したらすぐ分かる」感覚は気持ちいいものです。

カバレッジ設定と CI 連携

テストを書いたら、どこまでカバーしたか知りたくなります。カバレッジレポートがその役目です。

基本設定

vitest.config.ts にカバレッジを追加:

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

export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',      // または 'istanbul'。v8 の方がデフォルトで速い
      reporter: ['text', 'html', 'json-summary'],
      reportsDirectory: './coverage',
      include: ['src/**/*.ts'],
      exclude: ['src/**/*.test.ts', 'src/types/**'],
      thresholds: {
        statements: 80,
        branches: 75,
        functions: 80,
        lines: 80,
      },
    },
  },
})

provider は v8istanbul。v8 は V8 エンジンのネイティブ API で速く、istanbul は古くからの選択肢で互換性重視。Vite / Node だけなら v8 で十分なことが多いです。

reporter は出力形式:text はターミナル、html は可視レポート、json-summary は CI 向け。

しきい値の考え方

thresholds は 4 軸あります:

  • statements:文が実行された割合
  • branches:if / else など分岐が両方テストされたか
  • functions:関数が呼ばれた割合
  • lines:行ベースのカバー率(statements と似るが計算方法が異なる)

目安は 75〜85%。低すぎると意味が薄く、高すぎるとチームが疲弊します。境界チェックやエラー処理は 100% にしにくい部分もあります。

GitHub Actions 連携

カバレッジの真価は、CI で基準未達の PR を止められること。.github/workflows/test.yml に例:

- name: Run tests with coverage
  run: npm run test -- --coverage

- name: Check coverage threshold
  run: |
    COVERAGE=$(cat coverage/coverage-summary.json | jq '.total.lines.pct')
    if (( $(echo "$COVERAGE < 80" | bc -l) )); then
      echo "Coverage $COVERAGE% is below threshold 80%"
      exit 1
    fi

80% 未満なら PR はマージできません。提出前にテストを足す習慣がつきます。

レポートの読み方

npx vitest --coverage 後、ターミナルには例えば:

 % Stmts   % Branch   % Funcs   % Lines   Uncovered Line
----------|----------|----------|----------|----------------
  82.45    |   76.32   |   85.71   |   82.45   | 23-25, 67

Uncovered Line は未カバー行。coverage/index.html では緑がカバー済み、赤が未カバーです。

最初は 100% 狙いの強迫観念にもなりがちでしたが、80% 前後でコアロジックと主要分岐はだいたい押さえられます。残りは極端な境界で、無理にテストするとコスパが悪いことも多いです。

Mock 三種:vi.fn、vi.spy、vi.mock

テストで面倒なのは外部依存——API、タイマー、サードパーティライブラリ。Vitest は 3 つの Mock 手段を用意しています。

vi.fn():偽関数を作る

元の実装がどうでもよく、「どう呼ばれたか」だけ知りたいときは vi.fn()

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

  callMeMaybe(callback)

  expect(callback).toHaveBeenCalledTimes(1)
  expect(callback).toHaveBeenCalledWith('hello')
})

callback は新規関数で、呼び出し回数・引数・戻り値を記録します。mockReturnValuemockImplementation で挙動も指定できます。

vi.spy():本物の関数を監視

置き換えず、呼ばれたか・何が渡されたかを見たいときは vi.spyOn

test('console.log が呼ばれる', () => {
  const logSpy = vi.spyOn(console, 'log')

  greet('World')

  expect(logSpy).toHaveBeenCalledWith('Hello, World!')
  logSpy.mockRestore()  // 復元を忘れずに
})

spy は元の挙動を保ったまま監視します。終わったら mockRestore()。他テストへの影響を防ぐため必須です。

vi.mock():モジュール全体を置換

API レスポンスやライブラリごと差し替えるときは vi.mock

// axios をモック
vi.mock('axios', () => ({
  default: {
    get: vi.fn(() => Promise.resolve({ data: { name: 'test' } }))
  }
}))

test('getUser がユーザーデータを返す', async () => {
  const user = await getUser(1)

  expect(user.name).toBe('test')
  expect(axios.get).toHaveBeenCalledWith('/users/1')
})

vi.mock はファイル先頭にホイストされるため、関数内や if の中に書いても意味がありません。モック内容は他変数に依存させられない点に注意。

どれを選ぶ?

  • 偽関数だけ欲しい → vi.fn()
  • 本物を覗きたい → vi.spy()
  • モジュール丸ごと → vi.mock()

覚え方:fn は「作る」、spy は「覗く」、mock は「丸ごと差し替え」。

クリーンアップ

テスト同士が干渉しないことが信頼性の前提。各テスト後に Mock を片付けます:

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

または設定でグローバルに:

test: {
  restoreMocks: true
}

Vitest UI とデバッグのコツ

ターミナルでも足りますが、もっと直感的に見たいなら Vitest UI。

可視化 UI を起動

npx vitest --ui

ブラウザにテスト一覧と詳細が開きます。任意のテストをクリックすると出力、スタック、実行時間が見え、カバレッジボタンから HTML レポートにも飛べます。

失敗時のデバッグに向いています。ターミナルログを追わず、UI でエラーとコードを並べて、保存すれば自動更新。

watch モード:影響範囲だけ実行

日常開発では npx vitest の watch が便利。utils/formatPrice.ts を直せば関連テストだけ走り、全件再実行しません。

800 件超のプロジェクトでは、フル 4 秒に対し増分は数百 ms という差も珍しくありません。

デバッグの定番

単一テストだけit.only

it.only('このテストだけ単独実行', () => {
  // ...
})

スキップ.skip

it.skip('一旦スキップ', () => {
  // ...
})

スナップショット更新:コンポーネント構造を変えたあと

npx vitest -u  # --update の短縮形

console.log:古くて確実。Vitest は console 出力を結果にそのまま表示します。

よくあるエラー

エラー原因対処
Cannot find moduleパスエイリアス未設定vitest.config の alias を確認
vi.mock is not a functionimport 方法の誤りimport { vi } from 'vitest' を使う
テストのタイムゾーン不一致デフォルト UTCsetup で process.env.TZ = 'Asia/Shanghai'

特にタイムゾーンは CI でローカルだけ通る、という罠になりがち。半日かけて気づいた経験があります。

まとめ

要点は一つ。Vitest + TDD で、テストを書く苦痛をかなり減らせます。

速度面では Jest の数十秒が数秒になり、数字以上に体験が変わります。コード修正とテスト待ちの往復が減る。ESM ネイティブ、Vite 設定の再利用も、地味に効きます。

Red-Green-Refactor は最初は直感に反しても、数回回すと分かります。各ステップが小さく、都度検証でき、設計を一度に完璧にしなくていい。テストが不足と退行を教えてくれます。

カバレッジと Mock はツール層の話。習得すれば堅いテストが書けますが、本質は習慣——数字追いではなく、コードへの自信のため。

すでに Vite なら Jest からの移行コストは低い。npm add -D vitest と構文の微調整(ほぼ互換)で動きます。迷うなら小さなモジュールから watch の即時フィードバックを試してください。

今すぐ Vitest で最初のテストを走らせ、TDD のループを体感してみてください。「直したらすぐ分かる」安心感を知ると、テストが嫌いだった人でも考えが変わるかもしれません。

Vitest TDD 実践ワークフロー

TDD で関数をゼロから作り、カバレッジレポートを設定する

⏱️ 目安時間: 30 分

  1. 1

    ステップ1: Vitest をインストール

    Vite プロジェクトに Vitest を追加:

    npm add -D vitest

    追加設定は不要。Vitest が Vite 設定を自動で再利用します
  2. 2

    ステップ2: Red フェーズ:失敗するテストを書く

    テストファイルを作成し、必ず失敗するテストを書きます:

    import { describe, it, expect } from 'vitest'
    import { formatPrice } from './formatPrice'

    describe('formatPrice', () => {
    it('通貨をフォーマットする', () => {
    expect(formatPrice(1234.5)).toBe('¥1,234.50')
    })
    })

    npx vitest を実行し、テストが失敗(赤)することを確認
  3. 3

    ステップ3: Green フェーズ:最小コードを書く

    実装ファイルを作成し、テストを通す最小限のコードを書きます:

    export function formatPrice(value: number): string {
    return '¥1,234.50' // まずはハードコード
    }

    テストを実行し、成功(緑)を確認
  4. 4

    ステップ4: Refactor フェーズ:コードを最適化

    Intl.NumberFormat でリファクタリング:

    export function formatPrice(value: number): string {
    return new Intl.NumberFormat('zh-CN', {
    style: 'currency',
    currency: 'CNY',
    }).format(value)
    }

    テストが引き続き通ることを確認
  5. 5

    ステップ5: カバレッジを設定

    vitest.config.ts に追加:

    test: {
    coverage: {
    provider: 'v8',
    thresholds: { statements: 80, branches: 75 }
    }
    }

    npx vitest --coverage でレポートを確認

FAQ

Vitest と Jest の主な違いは?
Vitest は Vite 向けに設計され、ESM をネイティブサポート。Jest より 5〜10 倍速いです。Vite の設定(エイリアス、環境変数)を自動で再利用するため、Jest のように moduleNameMapper を別途設定する必要がありません。
TDD の Red-Green-Refactor サイクルはどう回す?
3 ステップのループです:

• Red:失敗するテストを先に書き、要件を定義
• Green:テストを通す最小コードを書く(ハードコードでも可)
• Refactor:テストの保護下で構造を最適化

各ステップは小さく、認知負荷が低く、テストが常に安全網になります。
カバレッジしきい値はどのくらいが妥当?
75〜85% を推奨します。低すぎると意味がなく、高すぎると疲弊します。80% ならコアロジックはほぼカバーでき、残り 20% は極端な境界ケースで、無理にテストすると時間の無駄になりがちです。
vi.fn / vi.spy / vi.mock はどう使い分ける?
シーンに応じて選択:

• vi.fn():新しい偽関数を作り、呼び出し回数と引数を記録
• vi.spy():既存関数を監視し、元の挙動を保持。終了後は mockRestore() が必要
• vi.mock():モジュール全体を置換。API やサードパーティライブラリのモック向け

覚え方:fn は「作る」、spy は「覗く」、mock は「丸ごと差し替え」。
Vitest の watch モードの利点は?
増分テストで影響を受けたファイルだけ実行。800 テストのフル実行が 4 秒でも、増分なら数百 ms。保存後すぐテストが走り、ウィンドウを切り替えずにフロー状態に入れます。
テストのタイムゾーン問題はどう解決する?
Vitest のデフォルトは UTC です。vitest.config.ts の setupFiles で process.env.TZ = 'Asia/Shanghai' を設定するか、テストファイルの先頭で手動設定してください。

6分で読めます · 公開日: 2026年4月29日 · 更新日: 2026年6月15日

関連記事

コメント

GitHubアカウントでログインしてコメントできます