Vitest での vi.hoisted による巻き上げ処理対応を外部モジュール化してみた

2024.02.15

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

Vitest においてインポートされたモジュールをモックできる vi.mock においては、呼び出し処理が巻き上げ(hoist)により必ずインポート前に実行されるため、例えばモジュール内の同じ関数に対して vi.mock により複数回モックを宣言した場合でも、最後の宣言のモックのみが使用されるという挙動となります。

この挙動のワークアラウンドとしては、vi.hoisted を使う方法があります。

今回は、Vitest での vi.hoisted による巻き上げ処理対応を外部モジュール化してみました。

環境

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

ソースコード

下記の外部モジュールを呼び出すソースコードを対象に 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 の記述が巻き上げ対応です。

巻き上げ対応が正常に行われていることを確認するため、正常系 1 と正常系 2 のテストケースで同じ関数のモックがそれぞれ異なる値を返すようにしています。

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("正常系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.2.2 /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:42:59
   Duration  228ms (transform 62ms, setup 0ms, collect 56ms, tests 2ms, environment 0ms, prepare 49ms)

モックをモジュール化した場合

vi.hoisted の記述は長くなるため、複数のテストファイルで同じモックを使用する場合は、モックをモジュール化したい場合があると思います。

モジュール化する場合は下記のように、モックを返す関数をエクスポートするモジュールを作成します。

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 { getMock } from "./mock";
import { createCompany } from "./create-company";

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.2.2 /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:42:31
   Duration  215ms (transform 65ms, setup 0ms, collect 57ms, tests 2ms, environment 0ms, prepare 45ms)

モックを直接エクスポートはできない

当初、下記のように vi.hoisted からモックを直接エクスポートしようとしました。

src/mock.ts

import { vi } from "vitest";

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

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

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

モックを使用する側のテストコードです。

src/create-company.test.ts

import { v4Mock, getExternalDataMock } from "./mock";
import { createCompany } from "./create-company";

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

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.2.2 /Users/wakatsuki.ryuta/projects/other/vitest-sample

 ❯ src/create-company.test.ts (0)

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯

 FAIL  src/create-company.test.ts [ src/create-company.test.ts ]
SyntaxError: Cannot export hoisted variable. You can control hoisting behavior by placing the import from this file first.
      1| import { vi } from "vitest";
       |                              ^
      2| export const { v4Mock, getExternalDataMock } = vi.hoisted(() => {
      3|   return {
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯

 Test Files  1 failed (1)
      Tests  no tests
   Start at  02:44:36
   Duration  208ms (transform 57ms, setup 0ms, collect 0ms, tests 0ms, environment 0ms, prepare 40ms)

おわりに

Vitest での vi.hoisted による巻き上げ処理対応を外部モジュール化してみました。

モックをモジュール化することで、複数のテストファイルで同じモックを使用する場合に、テストコードの記述が簡潔になるので、ぜひ活用してみてください。

参考

以上