Vitest で例外処理の巻き上げ対応をしてみた

2024.02.04

こんにちは、CX 事業本部製造ビジネステクノロジー部の若槻です。

Vitest においてインポートされたモジュールをモックできる vi.mock ですが、この呼び出しは巻き上げ(hoist)により必ずインポート前に実行される挙動となります。

今回は、Vitest で例外処理の巻き上げ対応をしてみました。

試してみた

環境作成

Node.js & TypeScript のプロジェクトを作成します。

# package.json を作成
npm init -y

# TypeScript をインストール
npm install typescript --save-dev

# TSConfig を作成
npx tsc --init --rootDir src --outDir lib --esModuleInterop --resolveJsonModule --lib es6,dom --module commonjs

# Node.js の型定義をインストール
npm install @types/node --save-dev

Vitest をインストールします。

npm i -D vitest

ソースコード

テスト対象のモジュールcreateCompanyのソースコードです。

src/create-company.ts

import * as Uuid from "uuid";

import { getExternalData } from "./utils";

export interface CreatedCompany {
  id: string;
  name: string;
  data?: string;
}

export const createCompany = async (params: {
  name: string;
}): Promise<CreatedCompany> => {
  const externalData = await getExternalData();

  return { id: Uuid.v4(), name: params.name, data: externalData };
};

createCompanyは、一定の割合でエラーを返す(例外処理を発生させる)getExternalData関数を呼び出します。

src/utils.ts

export const getExternalData = async (): Promise<string> => {
  // 一定の割合でエラーを返す
  if (Math.random() < 0.5) {
    throw new Error("external-data-error");
  }

  return "external-data";
};

巻き上げへの対処が不要なパターン

まずは巻き上げ対応が不要なパターンを確認します。巻き上げの挙動は vi.mock の記述がすべてインポート前に移動することのため、そもそもモックされた関数を利用するテストケースが 1 つしかない場合は、巻き上げへの対処は不要となります。

例えば次のような正常系のテストーケースのみの場合です。

src/create-company.test.ts

import { createCompany } from "./create-company";
import { describe, test, vi, expect, beforeAll } from "vitest";

describe("createCompany", (): void => {
  describe("正常系", (): void => {
    beforeAll((): void => {
      vi.mock("uuid", () => {
        return {
          v4: vi.fn().mockReturnValue("e3162725-4b5b-4779-bf13-14d55d63a584"), // dummy-id
        };
      });
      vi.mock("./utils", () => {
        return {
          getExternalData: vi.fn().mockResolvedValue("external-data"),
        };
      });
    });

    test("作成されたデータが取得できること", async (): Promise<void> => {
      const result = await createCompany({ name: "dummy-name" });

      expect(result).toEqual({
        id: "e3162725-4b5b-4779-bf13-14d55d63a584",
        name: "dummy-name",
        data: "external-data",
      });
    });
  });
});

テストを実行すると、正常にパスします。

$ npx vitest run --dir ./src

 RUN  v1.2.2 /Users/wakatsuki.ryuta/projects/other/vitest-sample

 ✓ src/create-company.test.ts (1)
   ✓ createCompany (1)
     ✓ 正常系 (1)
       ✓ 作成されたデータが取得できること

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  21:50:39
   Duration  240ms (transform 70ms, setup 0ms, collect 64ms, tests 1ms, environment 0ms, prepare 44ms)

巻き上げ発生によりテストが失敗する

次に、先程の正常系の後に異常系のテストケースを追加します。getExternalData に対するモックが先行の正常系では正常なデータを返し、後続の異常系ではエラーを返すようにします。

src/create-company.test.ts

import { createCompany } from "./create-company";
import { describe, test, vi, expect, beforeAll } from "vitest";

describe("createCompany", (): void => {
  describe("正常系", (): void => {
    beforeAll((): void => {
      vi.mock("uuid", () => {
        return {
          v4: vi.fn().mockReturnValue("e3162725-4b5b-4779-bf13-14d55d63a584"), // dummy-id
        };
      });
      vi.mock("./utils", () => {
        return {
          getExternalData: vi.fn().mockResolvedValue("external-data"),
        };
      });
    });

    test("作成されたデータが取得できること", async (): Promise<void> => {
      const result = await createCompany({ name: "dummy-name" });

      expect(result).toEqual({
        id: "e3162725-4b5b-4779-bf13-14d55d63a584",
        name: "dummy-name",
        data: "external-data",
      });
    });
  });

  describe("異常系", (): void => {
    beforeAll((): void => {
      vi.mock("uuid", () => {
        return {
          v4: vi.fn().mockReturnValue("e3162725-4b5b-4779-bf13-14d55d63a584"), // dummy-id
        };
      });
      vi.mock("./utils", () => {
        return {
          getExternalData: vi
            .fn()
            .mockRejectedValue(new Error("external-data-error")),
        };
      });
    });

    test("エラーがスローされること", async (): Promise<void> => {
      await expect(createCompany({ name: "dummy-name" })).rejects.toThrow(
        "external-data-error",
      );
    });
  });
});

テストを実行すると、先程パスしたはずの正常系テストが失敗し、追加した異常系テストのみが成功するようになりました。

$ npx vitest run --dir ./src

 RUN  v1.2.2 /Users/wakatsuki.ryuta/projects/other/vitest-sample

 ❯ src/create-company.test.ts (2)
   ❯ createCompany (2)
     ❯ 正常系 (1)
       × 作成されたデータが取得できること
     ✓ 異常系 (1)
       ✓ 作成されたデータが取得できること

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯

 FAIL  src/create-company.test.ts > createCompany > 正常系 > 作成されたデータが取得できること
Error: external-data-error
 ❯ src/create-company.test.ts:21:54
     20|       const result = await createCompany({ name: "dummy-name" });
     21| 
     22|       expect(result).toEqual({
       |                                                     ^
     23|         id: "e3162725-4b5b-4779-bf13-14d55d63a584",
     24|         name: "dummy-name",
 ❯ src/create-company.ts:2:31

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯

 Test Files  1 failed (1)
      Tests  1 failed | 1 passed (2)
   Start at  22:09:37
   Duration  265ms (transform 106ms, setup 0ms, collect 98ms, tests 5ms, environment 0ms, prepare 45ms)

これはvi.mockが巻き上げされたことにより、後続の異常系の vi.mock が先行の正常系の vi.mock を上書きしてしまったためです。

巻き上げに対処する

巻き上げに対処する場合は、vi.hoisted を利用して巻き上げを抑制します。

冒頭で各テストケースで共通で利用するモックを定義し、モックの振る舞いは各テストケースの beforeAll 内で定義します。

src/create-company.test.ts

import { createCompany } from "./create-company";
import { describe, test, vi, expect, beforeAll } from "vitest";

const { v4Mock, getExternalDataMock } = vi.hoisted(() => {
  return {
    v4Mock: vi.fn(),
    getExternalDataMock: vi.fn(),
  };
});
vi.mock("uuid", () => {
  return {
    v4: v4Mock,
  };
});
vi.mock("./utils", () => {
  return {
    getExternalData: getExternalDataMock,
  };
});

describe("createCompany", (): void => {
  describe("正常系", (): void => {
    beforeAll((): void => {
      v4Mock.mockReturnValue("e3162725-4b5b-4779-bf13-14d55d63a584");
      getExternalDataMock.mockResolvedValue("external-data");
    });

    test("作成されたデータが取得できること", async (): Promise<void> => {
      const result = await createCompany({ name: "dummy-name" });

      expect(result).toEqual({
        id: "e3162725-4b5b-4779-bf13-14d55d63a584",
        name: "dummy-name",
        data: "external-data",
      });
    });
  });

  describe("異常系", (): void => {
    beforeAll((): void => {
      v4Mock.mockReturnValue("e3162725-4b5b-4779-bf13-14d55d63a584");
      getExternalDataMock.mockRejectedValue(Error("external-data-error"));
    });

    test("エラーがスローされること", async (): Promise<void> => {
      await expect(createCompany({ name: "dummy-name" })).rejects.toThrow(
        "external-data-error",
      );
    });
  });
});

テストを実行すると、いずれのテストケースも正常にパスするようになりました。

$ npx vitest run --dir ./src

 RUN  v1.2.2 /Users/wakatsuki.ryuta/projects/other/vitest-sample

 ✓ src/create-company.test.ts (2)
   ✓ createCompany (2)
     ✓ 正常系 (1)
       ✓ 作成されたデータが取得できること
     ✓ 異常系 (1)
       ✓ 作成されたデータが取得できること

 Test Files  1 passed (1)
      Tests  2 passed (2)
   Start at  22:20:54
   Duration  182ms (transform 32ms, setup 0ms, collect 29ms, tests 2ms, environment 0ms, prepare 55ms)

おわりに

Vitest で例外処理の巻き上げ対応をしてみました。

今回は巻き上げによりモックの振る舞いが意図通りにならない例として例外処理を取り上げましたが、正常系のテストケースを複数回記述する場合も同様となります。

巻き上げは Jest には無い挙動であり、Vitest 初心者には最初のハマりどころになる箇所だと思うので気をつけましょう。

参考

以上