mock した関数が複数回呼ばれていることを保証するテストを少しだけ安全に書く

2024/09/01
2024/09/03

前の記事で Vitest で Table Driven Test (テーブル駆動テスト) のアプローチに関する小ネタ記事を書きました。

今回もテストに関する小ネタ記事です。

例として、handler という内部で createContent() を3回呼んでいる関数の処理があります。createContent() は内部に通信処理を持っていて、ユニットテストでは mock するケースと仮定します。記事の性質上エラーハンドリングは考慮していません。

import { createContent } from "./createContent";

const handler = async () => {
  // 1つ目のコンテンツを作成
  await createContent("content");
  // 2つ目のコンテンツを作成
  await createContent("content2");
  // 3つ目のコンテンツを作成
  await createContent("content3");

  return { status: 201, message: "ok"}
}

まずは率直にテストを書きます。ざっくりしたテストですが、テスト内で handler を呼び出して、createContent が呼ばれた回数と引数の値が正しいかを確認しています。toHaveBeenNthCalledWith を使うと、N 番目に呼ばれた関数の引数をテストすることができます。

import { afterEach, describe, expect, test, vi } from "vitest";
import { createContent } from "./createContent";
import { handler } from "./handler";

vi.mock("./createContent");

describe("handler ", () => {
  test("正常系", async () => {
    afterEach(() => {
      vi.restoreAllMocks();
    });

    // handler の実行
    const result = await handler();

    // 内部で呼ばれている createContent の引数を確認 
    expect(createContent).toHaveBeenNthCalledWith(1, "content");
    expect(createContent).toHaveBeenNthCalledWith(2, "content2");
    expect(createContent).toHaveBeenNthCalledWith(3, "content3");

    // handler の戻り値を確認 
    expect(result).toEqual({ status: 201, message: "ok" });
  });
});

このままでも問題なさそうですが、3 番目の expect(createContent).toHaveBeenNthCalledWith(3, "content3"); をコードから削除してもテストが通ります。toHaveBeenNthCalledWith では詳細についてはテストできますが、呼ばれた回数との間で不整合が起こる可能性があります。

これではテストの記述漏れで不具合が起こりそうですね。これを少しだけ安全に書き直してみます。

import { afterEach, describe, expect, test, vi } from "vitest";
import { createContent } from "./createContent";
import { handler } from "./handler";

vi.mock("./createContent");

describe("handler ", () => {
  test("正常系", async () => {
    afterEach(() => {
      vi.restoreAllMocks();
    });

    // handler の実行
    const result = await handler();

    const mockContents = [
      { args: "content" },
      { args: "content2" },
      { args: "content3" },
    ];

    // createContent が複数回呼ばれていることを確認 
    expect(createContent).toHaveBeenCalledTimes(mockContents.length);

    // 内部で呼ばれている createContent の引数を確認
    mockContents.forEach((mockContent, i) => {
      expect(createContent).toHaveBeenNthCalledWith(i + 1, mockContent.args);
    });

    // handler の戻り値を確認
    expect(result).toEqual({ status: 201, message: "ok" });
 });
});

先ほどのテストとの変更点は、mockContents に値をまとめてこれを元にテストしたこと、toHaveBeenCalledTimes を使って関数が呼ばれた回数をチェックしたことです。

これで、toHaveBeenNthCalledWithtoHaveBeenCalledTimes の整合性が取れてテスト漏れを防ぐことができます。{ args: "content3" } を削除すると、toHaveBeenCalledTimes のテストが落ちてくれるため、記述漏れがなくなります。これなら、複数回呼ばれた関数を安全にテストすることができそうです。

ちなみに、今回書き換えた関数は下記のようにヘルパー関数として切り出して使えるようにしても良さそうです。これならスマートにテストを書くことができそうです。

// モックした関数の呼び出す値と回数をテストする
const verifyMockCalls = <T>(mockFn: T, args: []) => {
  expect(mockFn).toHaveBeenCalledTimes(args.length);

  args.forEach((arg, index) => {
    expect(mockFn).toHaveBeenNthCalledWith(index + 1, arg);
  });
}

verifyMockCalls<Type>(createContent, mockContents);

簡単でしたが以上になります。内部で複数回呼ばれる関数をモックするケースは多々あると思います。ぜひ、今回のケースが役立てばと思います。