⚡️

Vitest + TypeScriptでテストを書いてみる(スパイ&モック編)

2025/02/02に公開

はじめに

こんにちは。
最近、Vitest でテストを書く機会が増えてきました。
本記事では、Vitest のスパイ・モック機能を利用して、関数やオブジェクト、グローバルオブジェクトのテスト方法を具体例とともに解説していきます。

カスタムフックのテストについての紹介は以下の記事を見ていただけたらと思います。
https://zenn.dev/rh820/articles/e95d5c685f1f46

vi.spyOn

vi.spyOn は、関数やオブジェクトのメソッドに対して、元の実装をそのまま利用しながら呼び出し状況(回数、引数、戻り値など)の記録するために使用します。
必要に応じて .mockImplementation() や .mockReturnValue() を使い、挙動を一時的に変更できます。

基本的なスパイの使い方

calculator.ts
export const calculator = {
  add(a: number, b: number) {
    return a + b;
  },
};
calculator.test.ts
import { calculator } from "./calculator";

test("add メソッドが呼ばれて正しい結果を返すこと", () => {
  // calculator.add メソッドにスパイを仕掛ける
  const spy = vi.spyOn(calculator, "add");

  // メソッドを呼び出す
  const result = calculator.add(1, 2);

  // 戻り値の検証
  expect(result).toBe(3);
  // メソッドが呼ばれたことを確認
  expect(spy).toHaveBeenCalled();
  // 引数が正しいことを確認
  expect(spy).toHaveBeenCalledWith(1, 2);

  // テスト後は元の実装に戻す(テスト間の副作用防止)
  spy.mockRestore();
});

部分的な実装の上書き

特定の引数の場合だけ挙動を変更するなど、部分的に実装を上書きすることも可能です。

math.ts
export const math = {
  multiply(a: number, b: number) {
    return a * b;
  },
};
math.test.ts
import { math } from "./math";

test("multiply が呼ばれた際、特定の引数では上書きした実装が実行されること", () => {
  // spy を作成し、特定の条件で実装を上書きする
  const spy = vi.spyOn(math, "multiply").mockImplementation((a, b) => {
    // 0 が含まれる場合は 0 を返す
    if (a === 0 || b === 0) {
      return 0;
    }
    // それ以外は元の実装を呼び出す
    return a * b;
  });

  // 元の実装が呼ばれる
  expect(math.multiply(3, 4)).toBe(12);

  // 0 が含まれているため、上書きされた実装が呼ばれる
  expect(math.multiply(0, 5)).toBe(0);

  // 呼び出し回数の検証
  expect(spy).toHaveBeenCalledTimes(2);

  // テスト後は元の実装に戻す
  spy.mockRestore();
});

localStorage のテスト

ブラウザ環境で利用される localStorage も、同様にスパイを仕掛けることでその動作を検証できます。

storage.ts
export const storage = {
  saveData(key: string, value: string) {
    localStorage.setItem(key, value);
  },

  loadData(key: string): string | null {
    return localStorage.getItem(key);
  },
};
storage.test.ts
import { storage } from "./storage";

describe("storage のテスト", () => {
  beforeEach(() => {
    // テスト前に localStorage をクリアしておく
    localStorage.clear();
  });

  test("saveData で localStorage.setItem が呼ばれること", () => {
    // setItem メソッドにスパイを仕掛ける
    const setItemSpy = vi.spyOn(Storage.prototype, "setItem");

    storage.saveData("testKey", "testValue");

    // setItem が呼ばれたことを検証
    expect(setItemSpy).toHaveBeenCalledWith("testKey", "testValue");

    // スパイを元に戻す
    setItemSpy.mockRestore();
  });

  test("loadData で localStorage.getItem が正しく値を返されること", () => {
    // テストデータを localStorage に保存
    localStorage.setItem("testKey", "storedValue");

    // getItem メソッドにスパイを仕掛ける
    const getItemSpy = vi.spyOn(Storage.prototype, "getItem");

    // テスト対象のメソッドを実行
    const value = storage.loadData("testKey");

    // getItem が呼ばれて戻り値が正しいことを検証
    expect(value).toBe("storedValue");

    // getItem が正しい引数で呼ばれたことを検証
    expect(getItemSpy).toHaveBeenCalledWith("testKey");

    // スパイを元に戻す
    getItemSpy.mockRestore();
  });
});

内部処理の呼び出し確認

複数の内部処理を組み合わせた関数のテストを行ってみます。
calculateComplex メソッドは、引数を足した後、その結果に対して掛け算を実施する処理になっています。

内部で呼ばれる add と multiply の呼び出しをそれぞれ検証してみます。

calculator.ts
export const calculator = {

  add(a: number, b: number) {
    return a + b;
  },

  multiply(a: number, b: number) {
    return a * b;
  },

  // 引数を足した後、その結果に対して掛け算を実施
  calculateComplex(a: number, b: number, multiplier: number) {
    const sum = this.add(a, b);
    return this.multiply(sum, multiplier);
  },
};
calculator.test.ts
import { calculator } from "./calculator";

test("calculateComplex で add と multiply が正しく呼ばれること", () => {
  const addSpy = vi.spyOn(calculator, "add");
  const multiplySpy = vi.spyOn(calculator, "multiply");

  const result = calculator.calculateComplex(2, 3, 4);

  // (2 + 3) * 4 = 20 を検証
  expect(result).toBe(20);

  // add と multiply がそれぞれ 1 回ずつ呼ばれたことを検証
  expect(addSpy).toHaveBeenCalledWith(2, 3);

  // add の戻り値が multiply の引数に渡されたことを検証
  expect(multiplySpy).toHaveBeenCalledWith(5, 4);

  // スパイを元に戻す
  addSpy.mockRestore();
  multiplySpy.mockRestore();
});

axios を使った API 通信のテスト

axios を使った API 通信のテストを行ってみます。
vi.mock を使用して axios のモックを作成することも可能ですが、ここでは vi.spyOn を使って axios の get メソッドをスパイして、API 通信が正しく行われることを検証してみます。

api.ts
import axios from "axios";

export type UserType = {
  id: number;
  name: string;
  email: string;
};

export type NewUserType = {
  name: string;
  email: string;
};

// ユーザーデータ取得
export const fetchUser = async (userId: number): Promise<UserType> => {
  const response = await axios.get<UserType>(
    `https://api.example.com/users/${userId}`
  );
  return response.data;
};

// ユーザー作成
export const createUser = async (userData: NewUserType): Promise<UserType> => {
  const response = await axios.post<UserType>(
    "https://api.example.com/users",
    userData
  );
  return response.data;
};
api.test.ts
import axios from "axios";
import { fetchUser, createUser } from "./api";

// 各テスト実行前にaxiosのモックをリセット
beforeEach(() => {
  vi.resetAllMocks();
});

describe("axios", () => {
  test("指定されたユーザー情報が取得できること", async () => {
    // axiosのgetの返り値を指定
    vi.spyOn(axios, "get").mockResolvedValue({
      data: { id: 1, name: "hodii", email: "test@example.com" },
    });

    const userId = 1;

    // fetchUser関数を実行
    const result = await fetchUser(userId);

    // axios.getが指定された引数で呼ばれたか確認
    expect(axios.get).toHaveBeenCalledWith("https://api.example.com/users/1");

    // fetchUser関数の返り値を確認
    expect(result).toEqual({ id: 1, name: "hodii", email: "test@example.com" });
  });

  test("新しいユーザーを作成できること", async () => {
    // axiosのpostの返り値を指定
    vi.spyOn(axios, "post").mockResolvedValue({
      data: { id: 2, name: "hodii", email: "user@example.com" },
    });

    const newUser = { name: "hodii", email: "user@example.com" };

    // createUser関数を実行
    const user = await createUser(newUser);

    // axios.postが指定された引数で呼ばれたか確認
    expect(axios.post).toBeCalledWith("https://api.example.com/users", newUser);

    // createUser関数の返り値を確認
    expect(user).toEqual({ id: 2, name: "hodii", email: "user@example.com" });
  });
});

vi.fn

vi.fn は、任意の実装を持つモック関数を作成するためのヘルパーです。
関数が呼ばれた際の引数や戻り値、呼び出し回数などを簡単に検証できるため、単体テストやコールバックのテストに適しています。

基本的なモック関数の作成

以下の例では、シンプルな加算する関数のモックを作成し、呼び出し状況を検証しています。

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

test("mockFunction が正しく計算すること", () => {
  expect(mockFunction(2, 3)).toBe(5);
  expect(mockFunction).toHaveBeenCalledWith(2, 3);
  expect(mockFunction).toHaveBeenCalledTimes(1);
});

グローバルな fetch のモック化

グローバルな fetch をモックして API 通信のテストを行う例です。

fetch.ts
export async function getData(url: string) {
  const response = await fetch(url);
  return response.json();
}

fetch.test.ts
import { getData } from "./fetch";

describe("getData", () => {

  // 各テスト前に fetch をモック化
  beforeEach(() => {
    vi.spyOn(global, 'fetch').mockImplementation(() =>
      Promise.resolve({
        json: () => Promise.resolve({ success: true }),
      } as Response)
    );
  });

  // 各テスト後にモック状態をリセット
  afterEach(() => {
    vi.clearAllMocks();
  });

  test("getData が fetch を呼び出し、期待するレスポンスが返されること", async () => {
    const data = await getData("https://api.example.com/data");
    expect(data).toEqual({ success: true });
    expect(fetch).toHaveBeenCalledWith("https://api.example.com/data");
  });
});

非同期処理での Promise の失敗

非同期関数でエラー処理を検証する際、vi.fn().mockRejectedValue を利用して Promise の失敗(エラー)を検証することができます。

fetch.ts
export const fetchData = async (url: string) => {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error("Network error");
  }
  return response.json();
};
fetch.test.ts
test("fetchData がネットワークエラー時に例外をスローされること", async () => {
  // fetch をモックして、エラー状態をシミュレーション
  global.fetch = vi.fn(() =>
    Promise.resolve({
      ok: false, // 失敗を示す
    } as Response)
  );

  // 例外が発生することを検証
  await expect(fetchData("https://api.example.com")).rejects.toThrow(
    "Network error"
  );
});

イベントハンドラのテスト

UI コンポーネントや DOM 操作において、イベントハンドラが正しく呼ばれるかを検証してみます。

utils.ts
export const setupButtonClick = (
  button: HTMLButtonElement,
  onClick: () => void
) => {
  button.addEventListener("click", onClick);
};

import { setupButtonClick } from "./utils";

utils.test.ts
test("ボタンのクリックイベントで onClick が呼ばれること", () => {

  // 仮想のボタン要素を作成
  const button = document.createElement("button");

  // モック関数を作成
  const onClickMock = vi.fn();

  // イベントハンドラを設定
  setupButtonClick(button, onClickMock);

  // クリックイベントを発火
  button.click();

  // コールバックが呼ばれたかを検証
  expect(onClickMock).toHaveBeenCalled();
});

タイマー処理と非同期処理の検証

タイマーを利用する処理のテストでは、フェイクタイマーを使うことで正確なタイミングを制御できます。

runAfterDelay.ts
export function runAfterDelay(callback: () => void, delay: number) {
  setTimeout(callback, delay);
}
runAfterDelay.test.ts
import { runAfterDelay } from "./runAfterDelay";

describe("runAfterDelay のテスト", () => {
  // 各テスト前にフェイクタイマーを有効化
  beforeEach(() => {
    vi.useFakeTimers();
  });

  // 各テスト後にタイマーとモック状態をリセット
  afterEach(() => {
    vi.clearAllTimers();
    vi.useRealTimers();
  });

  test("指定した遅延後にコールバックが呼ばれること", () => {

    // モック関数を作成
    const callback = vi.fn();

    // 3 秒後にコールバックを実行
    runAfterDelay(callback, 3000);

    // 3000ms 経過させる
    vi.advanceTimersByTime(3000);

    // コールバックが呼ばれたかを検証
    expect(callback).toHaveBeenCalled();

    // コールバックが 1 回だけ呼ばれたかを検証
    expect(callback).toHaveBeenCalledTimes(1);
  });
});

部分的なモジュールモック

math.ts
export const math = {
  add(a: number, b: number) {
    return a + b;
  },
  subtract(a: number, b: number) {
    return a - b;
  },
};
math.test.ts
import { math } from "./math";

vi.mock("./math", async () => {
  const actual = (await vi.importActual(
    "./math"
  )) as typeof import("./math");
  return {
    math: {
      ...actual.math,
      // add の実装を上書きして +1 を加える
      add: vi.fn((a: number, b: number) => a + b + 1),
    },
  };
});

test("部分的モックで add と subtract の挙動を確認", () => {
  // add はモック実装により +1 された結果になる
  expect(math.add(2, 3)).toBe(6);

  // subtract は元の実装が使われるため通常通りになる
  expect(math.subtract(5, 3)).toBe(2);
});

vi.mock

vi.mock は、モジュール全体をモック化するための手法です。外部依存の副作用がある処理(たとえば、API 通信)をテスト用に差し替える際に利用します。

モジュール全体のモック化

対象モジュールの全すべてモック関数に置き換える例です。

api.ts
export const getUserData = () => {
  // 実際には外部 API への通信など副作用のある処理
  return { id: 1, name: "Real User" };
};
api.test.ts
import { getUserData } from "./api";

// api モジュール全体をモック化
vi.mock("./api", () => ({
  getUserData: vi.fn(() => ({ id: 2, name: "Mocked User" })),
}));

describe("api.getUserData のモックテスト", () => {
  test("getUserData がモックされる", () => {
    const data = getUserData();
    expect(data).toEqual({ id: 2, name: "Mocked User" });
    expect(getUserData).toHaveBeenCalled();
  });
});

API 通信のモック化と非同期処理のテスト

外部 API 通信を行う処理を実際に呼び出すことなく、モック化してレスポンスを差し替える例です。

userService.ts
export const getUserData = async (userId: number) => {
  // 実際は外部 API にリクエストを送信
  const response = await fetch(`https://api.example.com/users/${userId}`);
  return response.json();
};
userService.test.ts
import { getUserData } from "./userService";

vi.mock("./userService", () => ({
  getUserData: vi.fn(async (userId: number) => {
    return { id: userId, name: "Mock User" };
  }),
}));

test("getUserData がモックされ、期待するユーザーデータを返されること", async () => {
  const userId = 5;
  const data = await getUserData(userId);

  expect(data).toEqual({ id: userId, name: "Mock User" });
  expect(getUserData).toHaveBeenCalledWith(userId);
});

クラスのモック化による依存オブジェクトの挙動検証

外部に依存する Logger クラスを使ってログ出力を行う処理がある場合、クラス全体をモック化してその利用状況や呼び出し引数を検証できます。

logger.ts
export class Logger {
  info(message: string) {
    console.log(message);
  }
}
userNotifier.ts
import { Logger } from "./logger";

export const notifyUser = (user: string) => {
  const logger = new Logger();
  logger.info(`User ${user} notified`);
  return true;
};
userNotifier.test.ts
import { notifyUser } from "./userNotifier";
import { Logger } from "./logger";
import { vi, test, expect } from "vitest";

// Logger クラスをモック化する
vi.mock("./logger", () => {
  return {
    Logger: vi.fn().mockImplementation(() => {
      return {
        info: vi.fn(),
      };
    }),
  };
});

test("notifyUser は Logger の info を正しく呼び出されること", () => {
  const result = notifyUser("Taro");
  expect(result).toBe(true);

  // Logger コンストラクタが呼ばれたことを検証
  expect(Logger).toHaveBeenCalled();

  // モック実装された Logger インスタンスの info メソッドの呼び出しを検証
  const loggerInstance = (
    Logger as unknown as { mock: { results: { value: any }[] } }
  ).mock.results[0].value;

  // info メソッドが正しく呼ばれたことを検証
  expect(loggerInstance.info).toHaveBeenCalledWith("User Taro notified");
});

テストのセットアップとクリーンアップ

Vitest では、テスト全体または各テストごとに事前準備や後処理を行うためのフックが提供されています。
これにより、モックの初期化やタイマーの管理、リソースの解放などが容易になります。

主なフック

beforeEach
各テスト実行前に毎回実行する処理を定義

例: モックの初期化、フェイクタイマーの有効化など

afterEach
各テスト実行後に毎回実行する処理を定義

例: モックのリセット、タイマーの解除、リソースの解放など

beforeAll / afterAll
テストファイル内の全テストの前後で 1 回だけ実行する処理を定義

以下は、タイマーを使った非同期処理のテスト時にセットアップとクリーンアップを行う例です。

describe("タイマーを利用した非同期処理のテスト", () => {
  beforeEach(() => {
    // 各テスト前にフェイクタイマーを有効化
    vi.useFakeTimers();
  });

  afterEach(() => {
    // 各テスト後にモックとタイマー状態をリセット
    vi.clearAllMocks();
    vi.useRealTimers();
  });

  test("setTimeout を使った処理が正しく呼ばれること", () => {
    const callback = vi.fn();

    setTimeout(callback, 2000);

    // タイマーを進める
    vi.advanceTimersByTime(2000);

    expect(callback).toHaveBeenCalled();
    expect(callback).toHaveBeenCalledTimes(1);
  });
});

まとめ

Vitest でのスパイ・モックの使い方と実践例について紹介しました。
テスト対象のコードに外部依存がある場合や、非同期処理を含む場合でも、Vitest のスパイ・モック機能を活用することで、テストの信頼性を高めることができることが分かりました。
今後は React コンポーネントや、カスタムフックのテストについても紹介していく予定です。

GitHubで編集を提案

Discussion