VitestでAPIのユニットテストをはじめる

新しいプロジェクトでテストを導入するとき、「何をテストすべきか」「モックはどこまで使うべきか」で手が止まることがある。

とくにAPIを叩く処理は、外部依存が絡むためテスト対象の境界が曖昧になりやすい。ここではVitestを使い、APIクライアントのユニットテストを書く際の考え方と具体的な書き方を整理する。

目次

Vitestを選ぶ理由

Vitestは Vite と同じトランスフォーマを共有するテストランナーで、ESModuleやTypeScriptをほぼ設定なしで扱える。

Jest互換のAPIを持つため describe / it / expect といった基本的な書き味は同じだが、起動速度とHMRライクなwatchモードが特徴になる。

項目VitestJest
起動速度速い(Vite由来)やや遅い
ESM対応ネイティブ設定が必要な場合がある
APIJest互換標準
設定ファイルvite.config 共有可jest.config を別管理

ViteベースのフロントエンドやNode.jsプロジェクトでは、ツールチェーンを統一できる点が導入のしやすさにつながる。

APIユニットテストで切り分ける範囲

ユニットテストでは「実際にネットワークへ出ない」ことが前提になる。

HTTPクライアントの呼び出しはモックに置き換え、関数のロジック(リクエスト整形、レスポンス変換、エラーハンドリング)を検証対象にする。

テスト コード APIクライアント (テスト対象) fetch / axios (モックで遮断) 破線部分はユニットテストの責務外

外部通信を実際に行うかどうかで、テストの種類は変わってくる。ユニットテストの段階では境界の外側に踏み込まない判断が、テストの安定性と実行速度を保つ鍵になる。

モックの基本パターン

Vitestには大きく2つのモック機構がある。vi.fn() で関数単位の差し替えを作る方法と、vi.mock() でモジュール全体を差し替える方法だ。

vi.fnで関数を差し替える

最も小さい単位のモック。引数で受け取った関数を置き換えるケースで使う。

ts

import { describe, it, expect, vi } from 'vitest'
import { fetchUser } from './user-client'

describe('fetchUser', () => {
  it('正常系: ユーザー情報を整形して返す', async () => {
    const mockHttp = vi.fn().mockResolvedValue({
      data: { id: 1, user_name: 'taro' },
    })

    const result = await fetchUser(1, mockHttp)

    expect(mockHttp).toHaveBeenCalledWith('/users/1')
    expect(result).toEqual({ id: 1, name: 'taro' })
  })
})

依存をコンストラクタや引数で受け取る設計にしておくと、vi.fn() だけでテストが完結する。

vi.mockでモジュールを差し替える

fetch や外部ライブラリを直接importしている場合は、モジュール単位での差し替えが必要になる。

import { describe, it, expect, vi, beforeEach } from 'vitest'
import axios from 'axios'
import { fetchUser } from './user-client'

vi.mock('axios')

describe('fetchUser', () => {
  beforeEach(() => {
    vi.mocked(axios.get).mockReset()
  })

  it('APIエラー時に例外を投げる', async () => {
    vi.mocked(axios.get).mockRejectedValue(new Error('network error'))

    await expect(fetchUser(1)).rejects.toThrow('network error')
  })
})

vi.mocked() を挟むと型補完が効くため、TypeScript環境では積極的に使いたい。

異常系を意識したテスト設計

正常系だけのテストは、実装が変わったときに壊れることはあっても、バグを捕まえる力は弱い。APIクライアントでは次のような観点を最低限カバーしておきたい。

  • 期待したパスとパラメータでリクエストされているか
  • レスポンスのキー名変換やネストの展開が想定通りか
  • HTTPエラー(4xx / 5xx)が発生した場合の振る舞い
  • タイムアウトやネットワーク断のハンドリング

これらをテーブル駆動で並べると、ケース追加のコストも下がる。

it.each([
  { status: 400, expected: 'BadRequest' },
  { status: 401, expected: 'Unauthorized' },
  { status: 500, expected: 'ServerError' },
])('status $status のとき $expected を返す', async ({ status, expected }) => {
  vi.mocked(axios.get).mockRejectedValue({ response: { status } })
  await expect(fetchUser(1)).rejects.toThrow(expected)
})

カバレッジ(coverage)

カバレッジ(coverage)は「テストがソースコードのどこをどれだけ実行したかを示す指標」です。テストを動かしたとき、対象コードのうち何%が実際に通過したかを数値で可視化します。

主な種類

種類何を測るか
行(Line)実行された行の割合
分岐(Branch)if/else や三項演算子の各分岐を通ったか
関数(Function)呼び出された関数の割合
ステートメント(Statement)文単位での実行率
function divide(a: number, b: number) {
  if (b === 0) {
    throw new Error('zero')  // ← このテストがないと分岐カバレッジが下がる
  }
  return a / b
}

divide(10, 2) だけテストすると行カバレッジは高く見えますが、b === 0 の分岐が未通過なので分岐カバレッジは50%になります。「行は通っているがバグは見逃している」状態を炙り出すのが分岐カバレッジの役割です。

Vitestでの計測

Vitestは標準でカバレッジ計測に対応しています。

npm install -D @vitest/coverage-v8
npx vitest run --coverage

実行するとターミナルにサマリが出て、coverage/ 配下にHTMLレポートが生成されます。ファイルごとに「どの行が通っていないか」が色分けで見えるので、テスト不足の箇所を特定するのに便利です。

目次