Vitest で外部モジュール化したモックが動作しなかったので対処してみた

2024.02.26

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

以前に、テスティングフレームワーク Vitest でのモックを使用したテストの記述を簡潔にする方法を以下エントリで紹介しました。

今回は、その Vitest で外部モジュール化したモックが動作しない事象に遭遇したので、対処してみました。

環境

$ npm ls vitest
vitest-sample@1.0.0 /Users/wakatsuki.ryuta/projects/other/vitest-sample
└── vitest@1.3.1

ソースコード

下記の外部モジュールを呼び出すソースコードを対象に Vitest によるテストを行ってみます。

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 };
};

呼び出される外部モジュールは下記の通りです。これをモック化します。

src/utils.ts

export const getExternalData = async (): Promise<string> => {
  return "external-data";
};

ここまでは前回の記事と同じコードです。

モックコード

vi.hoisted を使用して作成した uuid および ./utils のモックを、外部モジュール化します。

src/mock.ts

import { vi } from "vitest";

export const getMock = () => {
  const { v4Mock, getExternalDataMock } = vi.hoisted(() => {
    return {
      v4Mock: vi.fn(),
      getExternalDataMock: vi.fn(),
    };
  });

  vi.mock("uuid", () => {
    return {
      v4: v4Mock,
    };
  });

  vi.mock("./utils", () => {
    return {
      getExternalData: getExternalDataMock,
    };
  });

  return { v4Mock, getExternalDataMock };
};

これについても前回の記事と同じコードです。

テストコード

モック失敗パターン

さて、一見すると問題はなさそうな次のテストコードを試してみます。上述のソースコードおよびモックをインポートして使用しています。

src/create-company.test.ts

import { createCompany } from "./create-company";
import { getMock } from "./mock";

import { describe, test, expect, beforeAll } from "vitest";

const { v4Mock, getExternalDataMock } = getMock();

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

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

      expect(result).toEqual({
        id: companyId1,
        name: "dummy-name",
        data: "mock-data-1",
      });
    });
  });

  describe("正常系2", (): void => {
    const companyId2 = "917e1cbc-b8a2-9913-0388-330c021493b3";
    beforeAll((): void => {
      v4Mock.mockReturnValue(companyId2);
      getExternalDataMock.mockResolvedValue("mock-data-2");
    });

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

      expect(result).toEqual({
        id: companyId2,
        name: "dummy-name",
        data: "mock-data-2",
      });
    });
  });
});

Vitest でテストを実行すると Fail しました。v4 および getExternalData のモックが行われていないようです。

$ npx vitest run --dir ./src

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

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

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯

 FAIL  src/create-company.test.ts > createCompany > 正常系1 > 作成されたデータが取得できること
AssertionError: expected { …(3) } to deeply equal { …(3) }

- Expected
+ Received

  Object {
-   "data": "mock-data-1",
-   "id": "e3162725-4b5b-4779-bf13-14d55d63a584",
+   "data": "external-data",
+   "id": "4adf4dfb-9083-44fd-8df7-ace1f3428ced",
    "name": "dummy-name",
  }

 ❯ src/create-company.test.ts:19:22
     17|       const result = await createCompany({ name: "dummy-name" });
     18| 
     19|       expect(result).toEqual({
       |                      ^
     20|         id: companyId1,
     21|         name: "dummy-name",

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

 FAIL  src/create-company.test.ts > createCompany > 正常系2 > 作成されたデータが取得できること
AssertionError: expected { …(3) } to deeply equal { …(3) }

- Expected
+ Received

  Object {
-   "data": "mock-data-2",
-   "id": "917e1cbc-b8a2-9913-0388-330c021493b3",
+   "data": "external-data",
+   "id": "8494c582-2c51-4c9c-86e9-10d3d6ee72da",
    "name": "dummy-name",
  }

 ❯ src/create-company.test.ts:37:22
     35|       const result = await createCompany({ name: "dummy-name" });
     36| 
     37|       expect(result).toEqual({
       |                      ^
     38|         id: companyId2,
     39|         name: "dummy-name",

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/2]⎯

 Test Files  1 failed (1)
      Tests  2 failed (2)
   Start at  02:32:19
   Duration  483ms (transform 281ms, setup 0ms, collect 285ms, tests 7ms, environment 0ms, prepare 56ms)

モック成功パターン

実は、先程のモック失敗パターンのコードには、前回の記事より一箇所だけ変更がありました。それはモジュールのインポートの順番です。前述のコードでは、import { createCompany } from "./create-company"; の位置が import { getMock } from "./mock"; よりも前になっています。

これらの順番を入れ替えて再度テストを実行してみます。

src/create-company.test.ts

import { getMock } from "./mock"; // 成功パターンの場合の位置
import { createCompany } from "./create-company";
// import { getMock } from "./mock"; // 失敗パターンの場合の位置

import { describe, test, expect, beforeAll } from "vitest";

const { v4Mock, getExternalDataMock } = getMock();

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

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

      expect(result).toEqual({
        id: companyId1,
        name: "dummy-name",
        data: "mock-data-1",
      });
    });
  });

  describe("正常系2", (): void => {
    const companyId2 = "917e1cbc-b8a2-9913-0388-330c021493b3";
    beforeAll((): void => {
      v4Mock.mockReturnValue(companyId2);
      getExternalDataMock.mockResolvedValue("mock-data-2");
    });

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

      expect(result).toEqual({
        id: companyId2,
        name: "dummy-name",
        data: "mock-data-2",
      });
    });
  });
});

すると今度はテストが成功しました。

$ npx vitest run --dir ./src

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

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

 Test Files  1 passed (1)
      Tests  2 passed (2)
   Start at  02:35:10
   Duration  295ms (transform 107ms, setup 0ms, collect 94ms, tests 2ms, environment 0ms, prepare 53ms)

uuid および ./utils に対するモックの作成は、それぞれの実体がインポートされる前に行われる必要があるようです。

eslint-plugin-import を使っている場合

ESLint のプラグインである eslint-plugin-import を使用している場合、モジュールのインポートの順序に関するルールが設定されていることがあります。その場合、モックのインポートを前に移動することで、次のようにルールに違反する可能性があります。

その場合は、次のようにコード内の一部分のみルールを無効化することで回避できます。

import { getMock } from "./mock";
// eslint-disable-next-line import/order
import { createCompany } from "./create-company";

おわりに

Vitest で外部モジュール化したモックが動作しなかったので対処してみました。

しかし全てのテストコードに eslint-plugin-import を無効化する記述を入れる運用とするのはスマートではないので、.eslintrc.json などで順序ルールをカスタマイズすると良さそうです。これに関しては次回以降のエントリで取り上げたいと思います。

以上