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

「何をテストすべきか」「モックはどこまで使うべきか」で手が止まることがある。

ここでは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のモックはvi.fn() で関数単位の差し替えを作る方法と、vi.mock() でモジュール全体を差し替える方法だ。

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

最も小さい単位のモック

vi.fn() にはオプションでモック実装となる関数を引数として渡せます

やることは2つだけです。

  1. 呼び出しを記録する(引数、戻り値、回数)
  2. 実装を自由に差し替えられる
const mock = vi.fn()

mock('hello')
mock(42)

mock.mock.calls    // => [['hello'], [42]](引数の記録)
mock.mock.results  // => [{ type: 'return', value: undefined }, ...](戻り値の記録)

vi.fn() は「監視機能つきの空っぽな関数」を作ります

呼び出すとモック関数(Mock Function)を1つ返します。

普通の関数と違い、「いつ・誰に・どんな引数で呼ばれたか」を内部に記録し続け、テストから検証できる点が特徴です。

最小例

import { vi } from 'vitest'

const fn = vi.fn()

fn()              // 呼べる(戻り値は undefined)
fn('hello', 42)   // 呼べる(戻り値は undefined)

console.log(fn.mock.calls)
// [[], ['hello', 42]]

fn.mock.calls には「各呼び出しの引数の配列」が記録されています。

vi.fn() で作った関数の機能

  1. 呼び出し履歴の記録(spy機能)
const fn = vi.fn()
fn('a')
fn('b', 'c')

fn.mock.calls         // [['a'], ['b', 'c']]
fn.mock.calls.length  // 2
fn.mock.results       // [{ type: 'return', value: undefined }, ...]

これを使ってテスト側で「呼ばれたか」「正しい引数で呼ばれたか」を検証します。

toHaveBeenCalledWith は、モック関数が特定の引数で呼ばれたかどうかを検証するマッチャー。

expect(fn).toHaveBeenCalled()
expect(fn).toHaveBeenCalledTimes(2)
expect(fn).toHaveBeenCalledWith('a')
expect(fn).toHaveBeenLastCalledWith('b', 'c')
  1. 戻り値の制御

デフォルトでは undefined を返しますが、後から戻り値を設定できます。

メソッド用途
mockReturnValue(v)同期関数として v を返す
mockResolvedValue(v)async関数として Promise<v> を返す
mockRejectedValue(e)async関数として Promise.reject(e) を返す
mockImplementation(fn)任意の実装に差し替える
const fn = vi.fn()

fn.mockReturnValue(42)
console.log(fn())  // 42

fn.mockResolvedValue({ id: 1 })
console.log(await fn())  // { id: 1 }

fn.mockRejectedValue(new Error('boom'))
await fn()  // throws Error('boom')

fn.mockImplementation((x) => x * 2)
console.log(fn(5))  // 10

呼び出しごとに違う値を返すmockResolvedValueOnce を使うケース

同じテスト内で、同じ関数が複数回呼ばれ、呼び出しごとに違う値を返したい場合です

const fn = vi.fn()
fn.mockReturnValueOnce('一回目')
fn.mockReturnValueOnce('二回目')
fn.mockReturnValue('それ以降ずっと')

fn()  // '一回目'
fn()  // '二回目'
fn()  // 'それ以降ずっと'
fn()  // 'それ以降ずっと'

初期実装を渡すパターン

引数なしで vi.fn() を呼ぶと「何もしない関数」ですが、引数に関数を渡すと「その関数が初期実装」になります。

const fn = vi.fn((a: number, b: number) => a + b)

fn(2, 3)  // 5
expect(fn).toHaveBeenCalledWith(2, 3)

「監視はしたいけど、本物っぽい挙動も欲しい」ときに便利です。

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環境では積極的に使いたい。

役割がまったく別物です。並べるとこう。

vi.mock()vi.mocked()
種類関数(副作用あり)型ヘルパー(実行時は何もしない)
目的モジュール自体をモックに差し替える既にモック化された値にTypeScriptの型を付ける
ランタイム動作モジュールを丸ごと置換引数をそのまま返すだけ(identity function)
位置ファイル先頭にホイストされる使いたい箇所でその都度

実際の使い分け

import { vi } from 'vitest'
import { fetchUser } from './api'
import { getUserName } from './userService'

vi.mock('./api') // ← モジュール全体をモックに置換(ホイスト)

test('ユーザー名を返す', async () => {
  // これだと型エラー:fetchUser は型上ただの関数で、mockResolvedValue を持たない
  // fetchUser.mockResolvedValue({ id: 1, name: 'Alice' })

  // vi.mocked で「これはモックだよ」と型情報を付与
  vi.mocked(fetchUser).mockResolvedValue({ id: 1, name: 'Alice' })

  expect(await getUserName(1)).toBe('Alice')
})
  • vi.mock を書いた時点で、実行時には既にモックになっている
  • vi.mocked何もしない。ただ TypeScript に「この値は Mock 型として扱って」と伝えるだけのキャスト的ヘルパー
  • なので vi.mocked 単体で使ってもモック化はされない。必ず先に vi.mock なり vi.fn なりでモック化されている前提

JavaScript(.js)で書いているなら vi.mocked はそもそも不要で、fetchUser.mockResolvedValue(...) と直接書けます。TypeScript 特有の悩みを解消するためのユーティリティという位置づけです。

it関数は、個々のテストケース(検証の最小単位)を定義して実行する役割を持ちます。

第1引数に「テストの内容(文字列)」、第2引数に「実際の検証処理を含むコールバック関数」を受け取ります。

import { it, expect } from 'vitest';

it('1たす1は2になること', () => {
  expect(1 + 1).toBe(2);
});

ittest のエイリアス(別名)で、動作はまったく同じです。

使い分けは好みの問題ですが、慣習として次のような傾向があります。

  • it: BDD(振る舞い駆動)スタイル。describe と組み合わせて「it should return 3(3を返すべき)」のように英文として読めるように書く。
  • test: シンプルに「これはテストだ」と書きたいとき。describe を使わない場合にも自然。

describe関数は、関連する複数のテスト(ittest)を論理的なグループにまとめる役割を持ちます。

引数はdescribe(name, fn) の2つです。

  • name: テストのグループ名(文字列)。テスト結果に表示されるラベルです。
  • fn: そのグループに属するテストをまとめた関数。中に it / test や、ネストした describe を書きます。
import { describe, it, expect } from 'vitest'

describe('add関数', () => {
  it('1 + 2 は 3', () => {
    expect(1 + 2).toBe(3)
  })
})

また、修飾子付きの形もあります。

  • describe.skip: スキップする
  • describe.only: これだけ実行する
  • describe.concurrent: 中のテストを並列実行する
  • describe.each([...]): 同じテストを複数データで繰り返す

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

正常系だけのテストは、実装が変わったときに壊れることはあっても、バグを捕まえる力は弱い。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)
})

制御文字のバリデーション

制御文字は、画面に表示される文字ではなく、動作を指示するための文字です。ASCII 0x00〜0x1F と 0x7F の範囲。

  • \0 (0x00) — ヌル文字。文字列の終端を示す。セキュリティ上最も危険
  • \t (0x09) — タブ
  • \n (0x0A) — 改行 (LF)
  • \r (0x0D) — 復帰 (CR)。Windowsの改行は \r\n
  • \b (0x08) — バックスペース
  • \x1B (0x1B) — エスケープ。ターミナルの表示を操作できてしまう

API開発で問題になるのは主に2パターンです。

入力に紛れ込むと困るもの

ユーザー名やメールアドレスなどにヌル文字やエスケープシーケンスが入ると、ログ汚染やインジェクションの原因になります。

// 制御文字を検出する正規表現(タブ・改行・CRを除外する例)
const hasControlChars = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/.test(input);

許可すべきもの

テキストエリアなど複数行入力のフィールドでは \n\t は正当な入力なので、フィールドの用途に応じて許可範囲を変えます。

実務的には「改行とタブだけ許可し、それ以外の制御文字は弾く」というのが一般的な方針です。

どんな関数の単体テストでも同じステップで進められる

  • 関数の中身を一旦忘れて、外から見た振る舞いだけに注目
    • 引数(外部依存の戻り値も)に何を渡せるか
      • 引数以外も関数内から呼び出している「自分のコントロール外のもの」全ての入力と考えれるもの
        importしている他モジュール、API、環境変数、Date.now() のような非決定的なもの
      • 戻り値は何か
      • エラーを投げるか
      • 外部に対して何か副作用を起こすか(DBへの書き込み、API呼び出しなど)

カバレッジ(coverage)

カバレッジ(coverage)は「テストがソースコードのどこをどれだけ実行したかを示す指標」です。

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

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

テストを動かしたとき、対象コードのうち何%が実際に通過したかを数値で可視化します。

種類何を測るか
行(Line)実行された行の割合
分岐(Branch)if/else や三項演算子の各分岐を通ったか
関数(Function)呼び出された関数の割合
ステートメント(Statement)文単位での実行率

実行するとターミナルにサマリが出て、coverage/ 配下にHTMLレポートが生成されます。

ファイルごとに「どの行が通っていないか」が色分けで見えるので、テスト不足の箇所を特定するのに便利です。

function divide(a: number, b: number) {
  if (b === 0) {
    throw new Error('zero')  // ← このテストがないと分岐カバレッジが下がる
  }
  return a / b
}

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

Uncovered Lines を起点にテストを足していく

カバレッジレポートで実務的に一番使えるのが、右端の Uncovered Line #s 列です。「カバレッジが60%です」と言われても何から手をつけるか分かりませんが、この列は「何行目が通っていないか」をピンポイントで教えてくれるので、エディタでその行に飛べばテストケースが自然と決まります。

レポートは次のような構造で出力されます。未通過行の並び方のパターンで、テストの足し方を切り替えるのがコツです。

% Coverage report from v8 File % Stmts % Branch Uncovered Lines api/users.ts 100 100 api/orders.ts 91.3 68.5 42, 57, 89 lib/auth.ts 0 0 1-180 utils/format.ts 76.0 75.0 12-18 1-180 のような連続範囲 ファイルごとテストが未作成。 まず正常系1ケースだけでも 書けば、一気にカバレッジが 上がりやすい「費用対効果の 高い」箇所です。 42, 57, 89 のような飛び地 分岐やエラーハンドリングが 抜けているサイン。該当行を 開き「この条件を通すには何を 渡せばよいか」を逆算すると テストケースが見えてきます。

vitest run --coverage を実行すると、デフォルトでプロジェクトルートに coverage/ ディレクトリが生成されます。中身は主にHTMLレポートで、ブラウザで coverage/index.html を開くと、

カバレッジ100%は捨てていいです。守りたい契約に対してテストがあるか、の方が重要です

100%を目指すのが目的ではなく、「数字の低い箇所・抜けている分岐が、本当にテストすべき箇所かどうかを人間が判断する」ための道具としてカバレッジを使うのが健全

目次