JavaScript

Vitestの使い方|vi.fn・vi.mockでfetchを差し替える最小構成

VitestでAPIを呼ぶ関数をテストする最小構成を紹介。vi.fnとvi.stubGlobalによるfetchの差し替え、vi.mockでのモジュール置換、日時の固定と復元、coverage設定まで、実行確認済みのコードで解説します。

この記事の目次
  1. 最小構成を作り、vitest runで一度実行する
  2. vi.fnとvi.stubGlobalで、fetchだけを差し替える
  3. vi.mockで、読み込むHTTPモジュールを差し替える
  4. vi.mockは、書いた位置の順に動くとは限らない
  5. 同じファイル内の内部呼び出しまで置き換えたと思わない
  6. 時刻とモックの状態を、次のテストへ残さない
  7. coverageの対象を決め、モックで確認できない部分を残す

VitestでAPIを呼ぶ関数をテストするなら、まず検証したい関数はそのまま動かし、外側の通信だけを差し替える形から始めると分かりやすくなります。vi.fn()で偽物の関数を作り、vi.stubGlobal()やvi.mock()で実際に使われる場所へ差し込みます。

ここでは「APIからお知らせを取得し、タイトルと1分後の有効期限を返す」小さな例を作ります。Node上で動くユニットテストなので、APIサーバーの起動は不要です。Vitestの設定、対象コード、二つのテストファイルを順に揃えれば、9件のテストを実行できます。

最小構成を作り、vitest runで一度実行する

以下は独立した検証用ディレクトリの構成です。既存プロジェクトへ導入するときは、そのpackage.json全体を置き換えず、必要な依存とscriptsを追加します。Viteの既存設定にaliasやpluginsがある場合も、その設定を引き継ぐ方法を確認してください。

vitest-example/
  package.json
  vitest.config.js
  src/
    http.js
    notice.js
  test/
    http.test.js
    notice.test.js

この例はNode 24.13.0、Vitest 5.0.0、Vite 7.3.6で確認しています。Vitestと@vitest/coverage-v8は同じ5.0.0に揃えています。

package.json

{
  "name": "vitest-fetch-notice-example",
  "private": true,
  "type": "module",
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "test:coverage": "vitest run --coverage"
  },
  "engines": {
    "node": ">=24.13.0 <25"
  },
  "devDependencies": {
    "vitest": "5.0.0",
    "@vitest/coverage-v8": "5.0.0",
    "vite": "7.3.6"
  }
}

vitest.config.js

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'node',
    include: ['test/**/*.test.js'],
    coverage: {
      provider: 'v8',
      include: ['src/**/*.js'],
      reporter: ['text', 'html', 'json-summary'],
      thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
    },
  },
});

全ファイルを保存したら、このディレクトリでnpm install、続いてnpm testを実行します。生成されたpackage-lock.jsonも保存すれば、別の環境ではnpm ciで同じ依存を入れられます。

コマンド 用途
npm test vitest runで一度実行して終了する
npm run test:watch 変更を監視して再実行する。終了はCtrl+C
npm run test:coverage 一度実行し、カバレッジを出力・判定する

environment: 'node'ではブラウザのDOMは使いません。この例で使うfetchとResponseは、指定したNode環境で利用できます。コンポーネントの表示テストを追加する場合は、別途その実行環境を選びます。

スポンサーリンク

vi.fnとvi.stubGlobalで、fetchだけを差し替える

最初は、HTTPレスポンスをJSONに変える関数を検証します。fetchJson自体をモックにすると、この関数のエラー判定を試せなくなります。差し替えるのはグローバルのfetchです。

src/http.js

export async function fetchJson(url) {
  const response = await fetch(url);
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  return response.json();
}

test/http.test.js

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fetchJson } from '../src/http.js';

let fetchMock;
beforeEach(() => {
  fetchMock = vi.fn();
  vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => vi.unstubAllGlobals());

describe('fetchJson', () => {
  it('成功したJSONを返し、指定URLを一度だけ呼ぶ', async () => {
    fetchMock.mockResolvedValue(new Response('{"title":"News"}', { status: 200 }));
    await expect(fetchJson('/api/notices/1')).resolves.toEqual({ title: 'News' });
    expect(fetchMock).toHaveBeenCalledExactlyOnceWith('/api/notices/1');
  });

  it('HTTPエラーを拒否する', async () => {
    fetchMock.mockResolvedValue(new Response('unavailable', { status: 503 }));
    await expect(fetchJson('/api/notices/1')).rejects.toThrow('HTTP 503');
  });

  it('ネットワークエラーを呼び出し側へ返す', async () => {
    const error = new TypeError('network unavailable');
    fetchMock.mockRejectedValue(error);
    await expect(fetchJson('/api/notices/1')).rejects.toBe(error);
  });

  it('HTTP成功でもJSONでない本文は拒否する', async () => {
    fetchMock.mockResolvedValue(new Response('not json', { status: 200 }));
    await expect(fetchJson('/api/notices/1')).rejects.toBeInstanceOf(SyntaxError);
  });
});

vi.fn()は呼び出しを記録できる関数を作ります。作っただけでは既存のfetchは変わらないので、vi.stubGlobal('fetch', fetchMock)で差し替えます。mockResolvedValue()ならPromiseが成功したときの値、mockRejectedValue()なら失敗時のエラーを指定できます。

describeで対象をまとめ、itに期待する振る舞いを書き、expectで結果を確認しています。非同期のresolves・rejectsにはawaitを付けます。検証が終わる前にテストだけが終了しないようにするためです。

ここでは本物のResponseを作り、正常なJSONと壊れたJSONを区別しています。HTTP 503はfetchのPromiseをrejectさせる設定ではなく、503のレスポンスを返す設定です。これにより、対象関数のresponse.ok判定を通して確認できます。Fetch API自体の流れは、fetchでJSONを取得する基本で確認できます。

テスト後のvi.unstubAllGlobals()は、置き換えたグローバルを元へ戻します。このファイルではテストごとに新しいモックを作るので、直前の呼び出し回数や戻り値も引き継ぎません。

vi.mockで、読み込むHTTPモジュールを差し替える

次は、そのHTTP関数を使うloadNoticeです。APIから受け取ったタイトルの前後の空白を除き、取得完了時点から1分後の有効期限を返します。表示側はこの期限を利用できますが、ここでは画面の更新やキャッシュ機構までは実装しません。

src/notice.js

import { fetchJson } from './http.js';

// API契約: { title: string }。表示用タイトルと、取得完了から1分後の期限を返す。
export async function loadNotice(id) {
  const data = await fetchJson(`/api/notices/${encodeURIComponent(id)}`);
  if (data === null || typeof data.title !== 'string') {
    throw new TypeError('Invalid notice');
  }
  return { title: data.title.trim(), expiresAt: Date.now() + 60_000 };
}

test/notice.test.js

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fetchJson } from '../src/http.js';
import { loadNotice } from '../src/notice.js';

// vi.mockはimportより先に処理される。factory内でモックを作る。
vi.mock(import('../src/http.js'), () => ({ fetchJson: vi.fn() }));

beforeEach(() => {
  fetchJson.mockReset();
  vi.useFakeTimers({ toFake: ['Date'] });
  vi.setSystemTime(new Date('2026-09-12T00:00:00.000Z'));
});
afterEach(() => vi.useRealTimers());

describe('loadNotice', () => {
  it('タイトルを整え、取得完了から1分後の期限を返す', async () => {
    fetchJson.mockResolvedValue({ title: '  News  ' });
    await expect(loadNotice('a/b')).resolves.toEqual({
      title: 'News',
      expiresAt: Date.parse('2026-09-12T00:01:00.000Z'),
    });
    expect(fetchJson).toHaveBeenCalledExactlyOnceWith('/api/notices/a%2Fb');
  });

  it.each([null, {}, { title: 42 }])('想定外のデータを拒否する: %j', async (data) => {
    fetchJson.mockResolvedValue(data);
    await expect(loadNotice('1')).rejects.toThrow('Invalid notice');
  });

  it('依存関数の失敗を成功データに変えない', async () => {
    const error = new Error('HTTP 503');
    fetchJson.mockRejectedValue(error);
    await expect(loadNotice('1')).rejects.toBe(error);
  });
});

このテストではhttp.jsのexportを、fetchJson: vi.fn()を持つモジュールへ置き換えています。notice.jsがimportするfetchJsonもそのモックになるため、実際のHTTP処理は動きません。一方、loadNoticeのタイトル整形やエラー判定はそのまま実行されます。

vi.mockは、書いた位置の順に動くとは限らない

Vitest公式のモジュールモック説明にあるとおり、vi.mock()はimportより先に処理されるよう変換されます。上のfactoryではvi.fn()をその場で作っています。ファイルの下で初期化する変数をfactoryから読もうとすると、まだ初期化されていない場合があります。

vi.mock(import('../src/http.js'), ...)のimport()は、対象モジュールを指定するために使っています。ここではモック化を実行時に遅らせるための書き方ではありません。まずはfactoryの中でexportを定義する形に留めると、初期化順で迷いにくくなります。

同じファイル内の内部呼び出しまで置き換えたと思わない

この例はHTTP処理をhttp.jsへ分けています。同じファイル内の関数同士が直接呼び合っている場合、exportだけを外側から差し替えても、その内部呼び出しまで置き換わるとは限りません。差し替えたい依存を別モジュールへ分けるか、引数として渡す設計を検討します。テストを通すためだけに実装を細かく分割するのではなく、どこを外部との境界にするかで決めます。

時刻とモックの状態を、次のテストへ残さない

有効期限の期待値が実行時刻で変わると、毎回同じ結果を比較できません。そこでvi.useFakeTimers({ toFake: ['Date'] })でDateだけを差し替え、vi.setSystemTime()で基準時刻を決めています。終了時はvi.useRealTimers()で戻します。

今回の関数はDate.now()を読むだけなので、タイマーを進める操作はありません。vi.setSystemTime()で時刻を変更しても、待機中のsetTimeoutが実行されたことにはなりません。タイマー処理を試す場合は、対象に合うfake timersの設定と、経過時間を進めるAPIを使います。公式の日時モック説明でも、時刻の固定と復元を確認できます。

この例の後片付け 戻すもの
vi.unstubAllGlobals() vi.stubGlobal()で置き換えたグローバル
fetchJson.mockReset() vi.fnで作ったモックの呼び出し履歴と設定した実装・戻り値
vi.useRealTimers() fake timersで置き換えた時刻・タイマーAPI

mockClear()は呼び出し履歴を消しますが、設定済みの戻り値は残ります。この例のように各テストで返すデータを指定するなら、先にmockReset()しておくと前の設定を持ち越しません。これらを呼んでも、vi.mock()で置き換えたモジュールが自動的に本物へ戻るわけではありません。

coverageの対象を決め、モックで確認できない部分を残す

npm run test:coverageを実行すると、coverage/にHTMLレポートなどが作られます。この例ではsrc/**/*.jsを対象にし、statements・branches・functions・linesの閾値をそれぞれ100にしています。小さな2関数の学習用設定であり、既存プロジェクト全体へ同じ数値をそのまま強制するものではありません。

実行結果は2ファイル・9テストが成功し、対象の2ソースで各指標100%でした。新しいソースを増やす場合は、テストから読み込まれたファイルだけを見て満足せず、coverage.includeに必要な対象が入っているか確認します。設定の詳細はVitestのcoverage設定を参照できます。

今回確かめたこと 今回だけでは確かめられないこと
HTTPステータス判定、JSON解析失敗、エラー伝播 実サーバーの認証、ネットワーク、ブラウザのCORS
モジュールから受け取った値の整形と期限 API側の本当のレスポンス形式、UIでの表示
指定したURLを呼ぶこと デプロイ先のルーティングがそのURLへ応答すること

外部依存を差し替えると、関数の振る舞いを速く安定して確認できます。ただし、モックのデータが実APIの契約と合っているかは別に確かめる必要があります。接続部分は結合テストやE2Eで補い、ユニットテストの100%をシステム全体の保証にはしないでください。

まずはnpm testを一度通し、成功・HTTPエラー・通信エラーの違いを見比べるところから始められます。AIへ追加のテストを頼む場合は、この実行コマンドと検証する契約を渡します。観点の作り方や生成結果の評価は、AIでテストコードを作る手順に続きます。

スポンサーリンク