• トップ
  • ブログ一覧
  • Vitest のモック(vi.fn, vi.spyOn, vi.mock)の使い分けについて
  • Vitest のモック(vi.fn, vi.spyOn, vi.mock)の使い分けについて

    背景

    Vitest でテストを書く際、公式ドキュメントを見るとモックに関するユーティリティが多数存在し、最初はどれを使うべきか悩んでしまいます。
    この記事では、テストができれば良いという状態から、テストの目的に合わせたモックユーティリティの使い分けができるような状態へのステップアップを目指します。

    ゴール

    • vi.fn,vi.spyOn,vi.mockのそれぞれの特徴と適した用途を理解する
    • 実際のサンプルコードを通して、実践的な使い分け方や注意点を学ぶ

    環境

    • vitest: 3.0.8
    • jsdom: 26.0.0

    用語の確認

    ソフトウェアテストで用いる一般的なモックに関する用語の定義を確認します。

    • スタブ: 関数の実装をテスト用に置き換える機能
    • スパイ: スタブ + 関数の呼び出しや引数などを監視する機能
    • モック: スパイ + 自身の検証を行う機能

    3 つの用語の関係性は、スタブ < スパイ < モックという包含関係になっているようです。

    参考

    補足:広義のモック

    今回調査してわかったのは、狭義のモックと広義のモックがある、ということです。多くの場合、「モック」という言葉が指すのは「テストする際に都合の悪い処理を都合の良い処理にする」くらいの概念として語られていることが多いです。先に紹介したモックの定義を「狭義のモック」とするなら、このようなモックを「広義のモック」と言えるかと思います。
    実際、Vitest の文脈において「モック」という言葉が使われるときは、広義のモックを指しているように思われます。この記事でも、モックという言葉を使用しますが、広義のモックとして捉えてください。

    Vitest におけるモック

    3 つのモックユーティリティ

    Vitest のドキュメントを見るとモックユーティリティの数に圧倒されますが、実際にはvi.fn,vi.spyOn,vi.mockの 3 つを押さえておけば、多くのテストケースに対応できるかと思います。
    また、これらは「用語の確認」で確認した、スタブ、スパイ、モックといった機能では分類されておらず、3 つとも「スパイ」の機能を持ちます。
    そのため、それぞれの違いについては、モックをする対象が何なのか、に着目するとわかりやすいです。

    • vi.fn: モック関数を作成し、関数をモックする
    • vi.spyOn: オブジェクトのメソッドをモックする
    • vi.mock: モジュール全体をモックする

    各モックユーティリティの具体的な使いどころ

    vi.fn: コールバック関数をモック

    vi.fnの使いどころとしては、以下のようなコールバック関数の呼び出しを確認したい場合が考えられます。

    1// Button.test.tsx
    2import { render, screen } from "@testing-library/react"
    3import userEvent from "@testing-library/user-event"
    4import { Button } from "./Button"
    5
    6test("送信ボタンのクリック時に、クリックイベントに指定したコールバック関数が呼び出される", async () => {
    7  const user = userEvent.setup() // userEventのインスタンスを作成
    8  const handleClickMock = vi.fn() // vi.fnでモック関数を作成
    9  render(<Button onClick={handleClickMock}>送信</Button>) // 作成したモック関数をイベントハンドラとして仕込む
    10
    11  await user.click(screen.getByRole("button", { name: "送信" })) // 送信ボタンのクリックを再現
    12
    13  expect(handleClickMock).toHaveBeenCalled() // 呼び出しを確認
    14})

    vi.spyOn: グローバルオブジェクトをモック

    vi.spyOnの使いどころとしては、グローバルオブジェクトをモックする場合が考えられます。
    以下のfetchUserDataをテストする際、処理の中で呼び出されるfetch部分は都合が悪いので、振る舞いを変更したいです。

    1// fetchUserData.ts
    2export async function fetchUserData(userId: string): Promise<Response | Error> {
    3  try {
    4    const res = await fetch(`/api/users/${userId}`)
    5    return await res.json()
    6  } catch {
    7    throw new Error()
    8  }
    9}

    以下はテスト例です。

    1// fetchUserData.test.ts
    2import { fetchUserData } from "./fetchUserData"
    3
    4test("ID:1のユーザ情報を取得したら、ID:1のユーザデータが返される", async () => {
    5  // vi.spyOnでfetchをモック
    6  const spy = vi.spyOn(global, "fetch").mockResolvedValue({
    7    json: () => Promise.resolve({ id: "1", name: "太郎" }),
    8  } as Response)
    9
    10  const userData = await fetchUserData("1") // fetchUserDataを実行
    11
    12  expect(spy).toHaveBeenCalledWith("/api/users/1") // fetchの呼び出しを確認
    13  expect(userData).toEqual({ id: "1", name: "太郎" }) // 返り値を確認
    14})

    補足

    vi.fnで作成したモック関数をglobal.fetchに代入することでvi.fnでもモックすることが可能ですが、個人的にはイミュータビリティの観点から避けた方が無難かと思います。

    参考

    また、グローバルオブジェクトはモジュールではないので、vi.mockではグローバルオブジェクトをモックできません。

    vi.mock: 外部 API をモック

    vi.mockの使いどころとしては、外部 API を丸ごとモックする場合が考えられます。
    具体例として、以下のfetchUserのテストを例に考えてみます。
    テストでは、fetchUserで呼び出されるaxiosの振る舞いをまとめて変更したいです。

    1// user.ts
    2import axios from "axios"
    3
    4export async function getUser(userId: string) {
    5  try {
    6    const res = await axios.get(`/api/users/${userId}`)
    7    return res.data
    8  } catch {
    9    throw new Error("ユーザーデータの取得に失敗しました")
    10  }
    11}
    12
    13export async function createUser(userId: string) {
    14  try {
    15    const res = await axios.post(`/api/users/${userId}`)
    16    return res.data
    17  } catch {
    18    throw new Error("ユーザー登録に失敗しました")
    19  }
    20}
    21
    22export async function deleteUser(userId: string) {
    23  try {
    24    const res = await axios.delete(`/api/users/${userId}`)
    25    return res.data
    26  } catch {
    27    throw new Error("ユーザーデータの削除に失敗しました")
    28  }
    29}

    以下はテスト例です。
    vi.mockはモジュールの振る舞いをまとめて指定できるため、非常に便利です。
    importOriginalを使用することで、振る舞いを指定しないメソッドは元の実装の振る舞いを取り込めます(importOriginalを使用せず振る舞いを指定しなかったものは、自動的にundefinedを返す関数に置き換えられます)。
    さらに、vi.mockedヘルパーを使用することで、モック対象の振る舞いをテストごとに変更することが可能になります。

    1// user.test.ts
    2import axios from "axios"
    3import { getUser, createUser, deleteUser } from "./user"
    4
    5vi.mock(import("axios"), async (importOriginal) => {
    6  const mod = await importOriginal()
    7  return {
    8    ...mod,
    9    default: {
    10      get: vi.fn().mockResolvedValue({
    11        data: { id: "1", name: "太郎" },
    12      }),
    13      post: vi.fn().mockResolvedValue({
    14        data: { id: "1", name: "太郎", created: true },
    15      }),
    16      delete: vi.fn().mockResolvedValue({
    17        data: { success: true },
    18      }),
    19    },
    20  }
    21})
    22
    23describe("ユーザー処理に関するテスト", () => {
    24  test("getUserが成功したら、ユーザーデータを返す", async () => {
    25    const result = await getUser("1")
    26
    27    expect(axios.get).toHaveBeenCalledWith("/api/users/1")
    28    expect(result).toEqual({ id: "1", name: "太郎" })
    29  })
    30
    31  test("createUserが成功したら、ユーザーデータを返す", async () => {
    32    const result = await createUser("1")
    33
    34    expect(axios.post).toHaveBeenCalledWith("/api/users/1")
    35    expect(result).toEqual({ id: "1", name: "太郎", created: true })
    36  })
    37
    38  test("deleteUserが成功したら、成功レスポンスを返す", async () => {
    39    const result = await deleteUser("1")
    40
    41    expect(axios.delete).toHaveBeenCalledWith("/api/users/1")
    42    expect(result).toEqual({ success: true })
    43  })
    44
    45  test("モックの振る舞いを動的に変更できる", async () => {
    46    // vi.mockedを使用して、axiosの振る舞いを変更
    47    vi.mocked(axios.get).mockResolvedValue({
    48      data: { id: "2", name: "花子" },
    49    })
    50
    51    const result = await getUser("2")
    52
    53    expect(axios.get).toHaveBeenCalledWith("/api/users/2")
    54    expect(result).toEqual({ id: "2", name: "花子" })
    55  })
    56
    57  test("エラーケースのテスト", async () => {
    58    // vi.mockedを使用して、エラーを投げるように設定
    59    vi.mocked(axios.get).mockRejectedValue(new Error())
    60
    61    await expect(getUser("3")).rejects.toThrow(
    62      "ユーザーデータの取得に失敗しました"
    63    )
    64    expect(axios.get).toHaveBeenCalledWith("/api/users/3")
    65  })
    66})

    3 つのモック初期化メソッド

    モックした内容をリセットするには、モック初期化メソッドを使用します。
    Vitest のモック初期化メソッドには、mockClear,mockReset,mockRestoreの 3 つがあります。
    違いは以下の通りになります。

    • mockClear: 監視内容のリセット
    • mockReset: 監視内容のリセット + vi.fnでモックした関数をundefinedを返す関数に置き換え
    • mockRestore: 監視内容のリセット + vi.spyOnでモックした関数の実装を元の実装に戻す

    先のaxiosをモックする例を使って、初期化メソッドの使用例を見てみましょう。

    1// vi.mock内でvi.fnを用いてaxios.getを置き換えている
    2vi.mock(import("axios"), async (importOriginal) => {
    3  const mod = await importOriginal()
    4  return {
    5    ...mod,
    6    default: {
    7      get: vi.fn().mockResolvedValue({
    8        data: { id: "1", name: "太郎" },
    9      }),
    10    },
    11  }
    12})
    13
    14test("モックの振る舞いを動的に変更できる", async () => {
    15  // vi.mockedを使用して、axiosの振る舞いを変更
    16  const spy = vi.mocked(axios.get).mockResolvedValue({
    17    data: { id: "2", name: "花子" },
    18  })
    19
    20  const result = await getUser("2")
    21
    22  expect(axios.get).toHaveBeenCalledWith("/api/users/2")
    23  expect(result).toEqual({ id: "2", name: "花子" })
    24
    25  spy.mockReset() // モック内容をリセット
    26
    27  expect(axios.get).not.toHaveBeenCalled() // 呼び出しのリセットを確認
    28  expect(axios.get("3")).toEqual(undefined) // axios.getを呼び出してもundefinedが返る
    29})

    使用するモックユーティリティによって初期化メソッドの振る舞いが変化する(Jest と異なる挙動あり)ので、実際に使用して挙動を確認することをおすすめします。

    参考

    補足: vi ユーティリティを使ったモック初期化

    モック初期化メソッドには、それぞれ対応した vi ユーティリティも用意されています。
    対応関係は以下の通りです。

    • vi.clearAllMocks: すべてのモックに対しmockClearを行う
    • vi.resetAllMocks: すべてのモックに対しmockResetを行う
    • vi.restoreAllMocks: すべてのモックに対しmockRestoreを行う

    これらはbeforeEachafterEachなどと一緒に使用します。

    参考

    各テストで初期化メソッドを記述する必要がなくなるので便利です。

    1describe("ユーザー処理に関するテスト", () => {
    2  // 各テスト後にresetMockを行う
    3  afterEach(() => {
    4    vi.resetAllMocks()
    5  })
    6
    7  test("テスト1", () => {})
    8  test("テスト2", () => {})
    9  test("テスト3", () => {})
    10})

    Tips

    例の中で紹介しきれなかった、是非知っておきたいモックに関する Tips を紹介します。

    vi.spyOn でオブジェクト以外をモックする

    以下のようにimport * as構文を使うと、モジュールから直接インポートした関数をモックすることが可能です。

    1// calc.ts
    2export function add(a, b) {
    3  return a + b
    4}
    5
    6// calc.test.ts
    7import * as calc from "./calc"
    8
    9test("商品の合計金額が正しく計算される", () => {
    10  const spy = vi.spyOn(calc, "add")
    11
    12  const total = calc.add(1, 2)
    13
    14  expect(spy).toHaveBeenCalledWith(1, 2)
    15})

    vi.mock で振る舞いを変えず監視だけする

    以下を記述すると、振る舞いは元の実装のまま、モジュール内の全ての関数を監視することが可能です。

    1import { add } from "./calc"
    2
    3vi.mock("./calc.ts", { spy: true })
    4
    5test("add関数の実行を確認", () => {
    6  add(1, 2)
    7
    8  expect(add).toHaveBeenCalledWith(1, 2)
    9})

    Vitest Browser Mode

    まだ experimental 段階ですが、jsdom 環境ではなく実際のブラウザ環境でテストを実行するブラウザモードが Vitest に実装されました。ブラウザモードの利点は以下の 2 つです。

    • より本番環境に近いテストが可能になる
    • Web API の使用が可能になり、fetchlocalStorageといったグローバルオブジェクトをモックせずテストできるようになる

    また、パフォーマンスとしては jsdom 環境よりは少しだけ遅くなるようですが、ほとんど変わらないレベルのようです。

    参考

    実際の使用感としては、import * as構文のvi.spyOnでモジュールをモックしようとすると型エラーが発生したり(参考)、Next.js のコンポーネントをテストするとエラーが出たり(参考)と、ドキュメントに書かれていない不具合にいくつか遭遇したので、これからの整備に期待したいです。

    まとめ

    Vitest の基本のモックユーティリティはvi.fn,vi.spyOn,vi.mockの 3 つで、それぞれの使い分けについてはモックをする対象が何なのか、に着目するとわかりやすい。

    • 関数をモックする場合: vi.fn
    • オブジェクトのメソッドをモックする: vi.spyOn
    • モジュール全体をモックする: vi.mock

    ライトコードでは、エンジニアを積極採用中!

    ライトコードでは、エンジニアを積極採用しています!社長と一杯しながらお話しする機会もご用意しております。そのほかカジュアル面談等もございますので、くわしくは採用情報をご確認ください。

    採用情報へ

    おすすめ記事

    エンジニア大募集中!

    ライトコードでは、エンジニアを積極採用中です。

    特に、WEBエンジニアとモバイルエンジニアは是非ご応募お待ちしております!

    また、フリーランスエンジニア様も大募集中です。

    background