68
38

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

テストフレームワークをJestからVitest に移管した手順と得た知見

Last updated at Posted at 2022-08-19

はじめに

以前こちらの記事で書いた github actions のパイプラインの高速化の検討について、高速テストフレームワークとして期待されている Vitest についても検証したと述べたのですが、
今回はその Vitest に関する移行検証記事です。

Vitest とは

  • vitest.dev
  • Vite 環境のために開発された高速テストフレームワーク
  • Jest と互換性がある
  • まだメジャーバージョンではない(記事執筆時v0.22.1)

結論

  • Vitest への移行結果
    Jest に比べて Vitest の方が 10%少しテスト実行速度が上昇していることが確認できました。

  • 移行量

    項目 実績
    修正ファイル数 388 ファイル
    修正テスト数 2067 ケース
  • テスト実行時間比較

    Vitest Jest
    280.07s 317.90s
  • Vitest 実行結果
    vitest

  • Jest 実行結果
    jest

移行前環境

  • React v17(CRA ベースで初期構築)
  • Typescript
  • npm ベース
  • Sonarqube による静的解析を利用するため、jest-sonar-reporterを利用
  • styled-component導入済
  • testing-libraryを使ったテスト
  • Enzymeのshallowを使った snapshot テスト
  • Enzymeのmountを使った snapshot テスト

移行時の Vitest バージョン

{
  "happy-dom": "^5.0.0",
  "vite": "^2.9.9",
  "vite-tsconfig-paths": "^3.5.0",
  "vitest": "^0.14.2",
  "vitest-sonar-reporter": "^0.2.1",
  "c8": "^7.11.3"
}

方針

  • 既存で Success となるテストは移行後も全て Success となること
  • 基本的には Vitest 公式の MigrationGuide に従って移行していく

手順

ライブラリインストール

npm i -D vitest #vitest の core
npm i -D @vitejs/plugin-react #vitest の react 対応
npm i -D happy-dom #vitest に直接依存はないが、js-dom 互換で SSR にも対応、パフォーマンスも倍以上速い らしい happy-dom を導入してみる
npm i -D vite #setup.ts の defineConfig のためにインストール
npm i @testing-library/jest-dom/extend-expect -D
npm i vite-tsconfig-paths -D #read from tsconfig paths and baseUrl

vite.config.ts の作成

  • vite.config.ts

    vite.config.ts
    import { defineConfig } from "vitest/config";
    import react from "@vitejs/plugin-react";
    import tsconfigPaths from "vite-tsconfig-paths";
    export default defineConfig({
      plugins: [react(), tsconfigPaths()],
      test: {
        globals: true,
        environment: "happy-dom", // jsdomの代わりにhappy-domを設定した
        setupFiles: ["./src/setup.ts", "./src/setupTests.ts"], //Jestで使っていたテスト設定をセットアップ
      },
    });
    

package.json に Vitest 起動用の script を入れる

  • vitest runで Vitest を実行できます

  • Vitest の場合は並列実行数(worker)はデフォルトだと、PC が許す限りのメモリ分並列実行させます

    • worker の数を制御したい場合はVITEST_MIN_THREADS=6 VITEST_MAX_THREADS=6で設定することができます
    • 今回は元々自分が Jest で設定していた worker の数と同じにしました(実行比較のため)
  • before(cra ベースの Jest)

    package.json
    {
      "scripts": {
        "test": "react-scripts test --env=jsdom --maxWorkers=6 --watchAll=false"
      }
    }
    
  • after

    package.json
    {
      "scripts": {
        "test": "VITEST_MIN_THREADS=6 VITEST_MAX_THREADS=6 vitest run"
      }
    }
    

これで基本的には Jest->Vitest への移行は完了です。
ピュアな Jest であれば Vitest でそのまま動くと思います。

テスト実行方法について

単体での実行

  • npm run test hoge.test.ts

  • ※Jest のようにパス付きファイル指定はできないもよう
    以下実行できませんでした
    npm run test ./src/pages/hoge/hoge.test.ts

ディレクトリ単位での実行

  • npm run test --dir ./src/pages/hoge/

ブランチ間の差分の実行

  • npm run test --changed origin/master

話がちょっと逸れますがこの機能めちゃめちゃ重要だと思います。
CICD パイプラインのテスト実行 step で威力を発揮します。
例えば PullRequest での毎回のテスト実行の際、このオプションをつけるだけで修正差分に関連するテストだけを自動で選択して実行してくれるので、
テスト実行時間やパイプラインの時間消費の効率が上がります。

vscode の設定

vscode のデバッガ設定

  • launch.json
    • node_modules の場所を指定するパスに注意
      launch.json
      {
        "version": "0.2.0",
        "configurations": [
          {
            "name": "vitest:Debug Current Test File",
            "type": "pwa-node",
            "request": "launch",
            "autoAttachChildProcesses": true,
            "skipFiles": ["<node_internals>/**", "**/node_modules/**"],
            "program": "${workspaceRoot}/node_modules/vitest/vitest.mjs",
            "args": ["run", "${relativeFile}"],
            "smartStep": true,
            "console": "integratedTerminal"
          }
        ]
      }
      

vscode の run 設定変更(settings.json)

  • Jest
    • jest-Runner extension をインストールする
  • Vitest
    • vitest-Runner extension をインストールする

coverage 設定の移行

cra ベースの場合だと coverage の設定は package.json で記載していると思いますが、それをvitest.config.tsへ移行します

  • Jest

    package.json
    {
      "jest": {
        "collectCoverageFrom": [
          "src/pages/**/*.{js,jsx,ts,tsx}",
          "src/common/**/*.{js,jsx,ts,tsx}",
          "src/index.tsx"
        ]
      }
    }
    
    • coverageフォルダの中にカバレッジファイルができる(lcov)
  • Vitest

    • Vitest での coverage 生成は c8 というライブラリ を使う
      npm i --save-dev c8

    • [追記:20230130 vitest:0.28.3] Vitest での coverage 生成は @vitest/coverage-c8 というライブラリ を使う
      npm i --save-dev @vitest/coverage-c8

      vite.config.ts
      export default defineConfig({
        plugins: [react(), tsconfigPaths()],
        test: {
          coverage: {
            reporter: ["text", "lcov"],
            reportsDirectory: "coverage",
            include: [
              "src/pages/**/*.{js,jsx,ts,tsx}",
              "src/common/**/*.{js,jsx,ts,tsx}",
              "src/index.tsx",
            ],
          },
        },
      });
      
      • textが標準出力、lcovが output ファイルを出力する

sonar-reporter の設定

  • Jest

    • 起動時の引数に--coverage --testResultsProcessor jest-sonar-reporterを入れる

      package.json
      {
        "scripts": {
          "test:ci": "react-scripts test --env=jsdom --maxWorkers=6 --watchAll=false --coverage --testResultsProcessor jest-sonar-reporter"
        }
      }
      
    • jest-sonar-reporterを使う

      package.json
      {
        "jestSonar": {
          "reportPath": "report",
          "reportFile": "test-report.xml",
          "reportedFilePath": "relative",
          "relativeRootDir": "<rootDir>/../",
          "indent": 4
        }
      }
      
  • Vitest
    vitest-sonar-reporterを使う

    • vitest-sonar-reporter
    • npm install --save-dev vitest-sonar-reporter
    • vite.config.ts
      vite.config.ts
      export default defineConfig({
        plugins: [react(), tsconfigPaths()],
        test: {
          reporters: ["default", "vitest-sonar-reporter"],
          outputFile: "report/test-report.xml",
        },
      });
      
      • reporters: ["default"]が標準出力を表す

Jest との差分

Mock の宣言

jest.mockvi.mockに置き換える必要があります。

  • Jest

    jest.mock(...)
    
  • Vitest

    import { vi } from 'vitest';
    vi.mock(...)
    

instance の型の明示的な設定

Typescript における mock の型の明示的な設定に違いがあります

  • jest
    • (MediaClient.getHoge as jest.Mock).mock.calls[0][0]
  • vitest
    • (MediaClient.getHoge as SpyInstanceFn).mock.calls[0][0]
    • [追記:20230130 vitest:0.28.3] (MediaClient.getHoge as unknown as SpyInstance)

automocking の未サポート

  • jest における automocking <root>/__mocks__は現状サポートしていません

  • momentを automocking していた例

    • before

      • <root>/__mocks__/moment.tsで mock していた
    • after

      • setup.ts

        setup.ts
        import "@testing-library/jest-dom/extend-expect";
        import { vi } from "vitest";
        import * as moment from "./__mocks__/moment";
        
        vi.doMock("moment", () => moment);
        

default を spyon できない

  • [追記:20230130 vitest:0.28.3] 新バージョンで解消済み

  • Jest での default spyon

    import * as getHogeApi from "../apicall/getHoge";
    
    const mockGetHogeApi = jest.spyOn(getHogeApi, "default");
    

    これを Vitest でそのまま実行すると以下のエラーになります

        TypeError: Cannot redefine property: default
       ❯ src/pages/hoge.test.tsx:18:39
           18| const mockGetHogeApi = vi.spyOn(getHogeApi, 'd…
             |                          ^
    
  • Vitest での置き換え

    • vi.mockvi.mockedを利用して置き換えます

      import { vi } from "vitest";
      vi.mock("../apicall/getHoge", async () => {
        const actual: any = await vi.importActual("../apicall/getHoge");
        const mocked = {};
        for (const key of Object.keys(actual)) {
          if (typeof actual[key] === "function") {
            mocked[key] = vi.fn(actual[key]);
          } else {
            mocked[key] = actual[key];
          }
        }
        return mocked;
      });
      
      import * as getReservePresetListApi from "../apicall/getHoge";
      
      const mockGetReservePresetListApi = vi.mocked(
        getReservePresetListApi
      ).default;
      
  • 参考

mock のやり方

  • defaultが必要なケースがあります

  • Jest

    jest.mock("moment", () => () => ({
      format: () => "2019–09–30T10:11:59+09:00",
    }));
    
  • Vitest

    vi.mock("moment", () => {
      return {
        default: () => ({
          format: () => "2019–09–30T10:11:59+09:00",
        }),
      };
    });
    

Done callback が非推奨

  • test 自体は通りますが、Vitest だとエラー判定されます。

    it(`test`, (done) => {
      // GIVEN
      // WHEN
      when();
    
      setTimeout(() => {
        try {
          // THEN
          expect();
          done(); // TypeError: An argument for 'value' was not provided.
        } catch (e) {
          done(e);
        }
      }, 300);
    });
    
    Serialized Error: {
      "errors": [
        [Error: done() callback is deprecated, use promise instead],
        [Error: done() callback is deprecated, use promise instead],
      ],
    }
    
  • 回避策

    • Done Callback

      • 上のドキュメントの実装でも通せますが、Typescript では done の引数エラーが発生します

        it(`test`, () =>
          new Promise((done) => {
            // GIVEN
            // WHEN
            when();
        
            setTimeout(() => {
              try {
                // THEN
                expect();
                done(); // TypeError: An argument for 'value' was not provided.
              } catch (e) {
                done(e);
              }
            }, 300);
          }));
        
      • new Promiseに型をつければエラーは消えますが、今度は引数ありのほうが逆にエラーとなります

        it(`test`, () =>
          new Promise<void>((done) => {
            // GIVEN
            // WHEN
            when();
        
            setTimeout(() => {
              try {
                // THEN
                expect();
                done(); // pass
              } catch (e) {
                done(e); // TypeError: An argument for 'value' was not provided.
              }
            }, 300);
          }));
        
      • 結論としてはdoneとは別にrejectを使用します

        it(`test`, () =>
          new Promise<void>((done, reject) => {
            // GIVEN
            // WHEN
            when();
        
            setTimeout(() => {
              try {
                // THEN
                expect();
        
                done();
              } catch (e) {
                reject(e);
              }
            }, 300);
          }));
        

リテラル表記のit.eachのエラー

  • Jest でのit.eachテストにてエラーが発生
it.each`
  input        | expFromTime | expToTime
  ${"morning"} | ${"04:00"}  | ${"10:00"}
  ${"noon"}    | ${"10:00"}  | ${"17:00"}
  ${"night"}   | ${"17:00"}  | ${"03:59"}
  ${"all"}     | ${null}     | ${null}
  ${"hoge"}    | ${null}     | ${null}
`(
  `timePeriodテスト
    timePeriod:$inputのケース`,
  async ({ input, expFromTime, expToTime }) => {
    // GIVEN
    // WHEN
    // THEN
);
  • vitest-eachに沿って書き換える

    • 公式通り修正

      it.each([
        ['morning', '04:00', '10:00'],
        ['noon', '10:00', '17:00'],
        ['night', '17:00', '03:59'],
        ['all', null, null],
        ['hoge', null, null],
      ])
      (
        `timePeriodテスト
        timePeriod:$sのケース`,
        async ( input, expFromTime, expToTime ) => {
        // GIVEN
        // WHEN
        // THEN
      );
      
      • printf 形式のパラメータ指定
        %s: string
        %d: number
        %i: integer
        %f: floating point value
        %j: json
        %o: object
        %#: index of the test case
        %%: single percent sign ('%')
      

require による import はサポート外

試験的な導入による warning

メジャーバージョンではないため出ている

(node:46501) ExperimentalWarning: buffer.Blob is an experimental feature. This feature could change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
(node:46501) ExperimentalWarning: buffer.Blob is an experimental feature. This feature could change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
  • 試験的な導入なので warning が出る。無視で OK です

エラーの対処一覧

外部ライブラリの default モックでエラー

  • 外部ライブラリであるsuperagentをモックしていたのですがエラーになりました

    vi.mock("superagent", () => ({
      put: vi.fn().mockReturnValue({
        set: vi.fn().mockReturnValue({
          send: vi.fn().mockReturnValue({
            end: vi.fn().mockImplementation((cb) => {
              cb("", {});
            }),
          }),
        }),
      }),
    }));
    
        Error: Test timed out in 5000ms.
        If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout".
        ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
    
        Test Files  1 failed | 1 passed (2)
             Tests  1 failed | 2 passed (3)
              Time  9.58s (in thread 5.02s, 190.59%)
    
        ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Unhandled Errors ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
    
        Vitest caught 1 unhandled error during the test run. This might cause false positive tests.
        Please, resolve all the errors to make sure your tests are not affected.
    
        ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Unhandled Rejection ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
        TypeError: __vite_ssr_import_0__.default.put(...).set is not a function
         ❯ src/apicall/hoge.ts:56:9
             54|     if (isOK(result)) {
             55|       request.put(result.data.url)
             56|         .set('Content-Type', contentType)
               |         ^
             57|         .send(file)
             58|         .end((error, uploadResult) => {
    
        ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
        error Command failed with exit code 1.
    
  • 以下のように書き換える必要がある(default の下に設定する)

    vi.mock("superagent", () => {
      return {
        default: {
          put: vi.fn().mockReturnValue({
            set: vi.fn().mockReturnValue({
              send: vi.fn().mockReturnValue({
                end: vi.fn().mockImplementation((cb) => {
                  cb("", {});
                }),
              }),
            }),
          }),
        },
      };
    });
    
  • vi.mock

vite.config.ts にてenvironment: happy-domを指定すると以下のエラーが発生する

  • styled-componentsとの併用で発生

  • happy-dom 側の問題みたいでした 5/24 時点

    The above error occurred in the <select> component:
    
        at select
        at div
        at div
        at I (/.../node_modules/styled-components/dist/styled-components.cjs.js:1:19220)
        at div
        at I (/.../node_modules/styled-components/dist/styled-components.cjs.js:1:19220)
        at div
        at div
        at div
        at ModalPortal (/.../node_modules/react-modal/lib/components/ModalPortal.js:70:5)
        at Modal (/.../node_modules/react-modal/lib/components/Modal.js:73:5)
        at PresetDialogLayout (/.../src/pages/Hoge/index.tsx:17:3)
        at LoadPresetDialog (/.../src/pages/Puga.tsx:19:3)
        at WrapperComponent (/.../node_modules/@wojtekmaj/enzyme-adapter-utils/build/createMountWrapper.js:112:7)
    
    Consider adding an error boundary to your tree to customize error handling behavior.
    Visit https://reactjs.org/link/error-boundaries to learn more about error boundaries.
    
    
    ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Unhandled Errors ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
    
    Vitest caught 1 unhandled error during the test run. This might cause false positive tests.
    Please, resolve all the errors to make sure your tests are not affected.
    
    ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Unhandled Rejection ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
    TypeError: Cannot read properties of undefined (reading 'length')
     ❯ updateOptions node_modules/react-dom/cjs/react-dom.development.js:1880:37
     ❯ postMountWrapper$2 node_modules/react-dom/cjs/react-dom.development.js:1950:5
     ❯ setInitialProperties node_modules/react-dom/cjs/react-dom.development.js:9157:7
     ❯ finalizeInitialChildren node_modules/react-dom/cjs/react-dom.development.js:10201:3
     ❯ completeWork node_modules/react-dom/cjs/react-dom.development.js:19470:17
     ❯ completeUnitOfWork node_modules/react-dom/cjs/react-dom.development.js:22812:16
     ❯ performUnitOfWork node_modules/react-dom/cjs/react-dom.development.js:22787:5
     ❯ workLoopSync node_modules/react-dom/cjs/react-dom.development.js:22707:5
     ❯ renderRootSync node_modules/react-dom/cjs/react-dom.development.js:22670:7
     ❯ performSyncWorkOnRoot node_modules/react-dom/cjs/react-dom.development.js:22293:18
    
    ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
    error Command failed with exit code 1.
    
  • vitest.config.tsenvironment:jsdomにすると解消されました

mapbox-gl のモックでエラー

  • jest で特に問題は出ていませんでしたが、vitest でエラーが発生しました
TypeError: The "obj" argument must be an instance of Blob. Received an instance of Blob
 ❯ define node_modules/mapbox-gl/dist/mapbox-gl.js:25:41
 ❯ node_modules/mapbox-gl/dist/mapbox-gl.js:35:1
 ❯ node_modules/mapbox-gl/dist/mapbox-gl.js:3:81
 ❯ Object.<anonymous> node_modules/mapbox-gl/dist/mapbox-gl.js:6:2

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
Serialized Error: {
  "code": "ERR_INVALID_ARG_TYPE",
}
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯

  • window.URL.createObjectURL Blob ERR_INVALID_ARG_TYPE

  • globalThis に Blob を追加してあげると回避できます

    • setup.ts

      import { Blob } from "buffer";
      
      (globalThis.Blob as any) = Blob;
      
  • [追記:20230130 vitest:0.28.3] Blob is not a constructor

    • 上記の設定をしてテスト実行するとTypeError: Blob is not a constructorというエラーが発生した
    • 解決
      • setup.tsのBlobの設定を戻し、createObjectURLのモックを設定する
          // import { Blob } from 'buffer'
          // (globalThis.Blob as any) = Blob;
          function noOp () { }
        
          if (typeof window.URL.createObjectURL === 'undefined') {
            Object.defineProperty(window.URL, 'createObjectURL', { value: noOp})
          }
        
        

global の__mocks__を使うために setup.ts で mock しようとしたらエラー

  • ある一定以上の数をsetup.tsで mock しようとするとエラーになりました

    ReferenceError: Cannot access '__vite_ssr_import_5__' before initialization
     ❯ src/setup.ts:3:48
          2| import { vi } from 'vitest';
          3| import * as moment from './__mocks__/moment';
          4| import * as momentTimezone from './__mocks__/moment-timezone.js';
           |  ^
          5| import * as superagent from './__mocks__/superagent';
          6| import * as leaflet from './__mocks__/leaflet.js';
     ❯ async src/__mocks__/leaflet.js:5:31
     ❯ async src/setup.ts:16:31
    
  • ReferenceError: Cannot access 'vite_ssr_import_0' before initialization when using mocks

    • mockではなくdoMockで回避できます

      • 修正前

        setup.ts
        import "@testing-library/jest-dom/extend-expect";
        import { vi } from "vitest";
        import * as moment from "./__mocks__/moment";
        import * as momentTimezone from "./__mocks__/moment-timezone";
        import * as superagent from "./__mocks__/superagent";
        
        vi.mock("moment", () => moment);
        vi.mock("moment-timezone", () => momentTimezone);
        vi.mock("superagent", () => superagent);
        
      • 修正後

        setup.ts
        import "@testing-library/jest-dom/extend-expect";
        import { vi } from "vitest";
        import * as moment from "./__mocks__/moment";
        import * as momentTimezone from "./__mocks__/moment-timezone";
        import * as superagent from "./__mocks__/superagent";
        import * as leaflet from "./__mocks__/leaflet";
        import { Blob } from "buffer";
        
        (globalThis.Blob as any) = Blob;
        vi.doMock("moment", () => moment);
        vi.doMock("moment-timezone", () => momentTimezone);
        vi.doMock("superagent", () => superagent);
        vi.doMock("leaflet", () => leaflet);
        

ArrayMove の mock エラー

  • TypeError: Cannot assign to read only property 'arrayMoveImmutable' of object '[object Module]'
import * as ArrayMove from "array-move";
vi.spyOn(ArrayMove, "arrayMoveImmutable");
import * as ArrayMove from "array-move";
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
vi.mocked(ArrayMove).arrayMoveImmutable;

jquery の import エラー(TypeError: default is not a function)

  • import $ from 'jquery';

    • TypeError: default is not a functionというエラーが発生
  • Jest

jest.mock("jquery", () => {
  const m$ = {
    DataTable: jest.fn().mockReturnValue({
      on: jest.fn(),
      destroy: jest.fn(),
    }),
    parents: jest.fn().mockReturnValue([
      {
        classList: {
          add: jest.fn(),
        },
      },
    ]),
  };
  return jest.fn(() => m$);
});
  • Vitest
    • default をつけてあげる
vi.mock("jquery", () => {
  const m$ = {
    DataTable: vi.fn().mockReturnValue({
      on: vi.fn(),
      destroy: vi.fn(),
    }),
    parents: vi.fn().mockReturnValue([
      {
        classList: {
          add: vi.fn(),
        },
      },
    ]),
  };
  return { default: vi.fn(() => m$) };
});

snapshotSaved がタイムアウトエラーを起こす

Vitest caught 1 unhandled error during the test run. This might cause false positive tests.
Please, resolve all the errors to make sure your tests are not affected.

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Unhandled Error ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
Error: Errors occurred while running tests. For more information, see serialized error.
 ❯ Object.runTests file:/.../node_modules/vitest/dist/chunk-vite-node-externalize.0094db73.js:7191:17
 ❯ processTicksAndRejections node:internal/process/task_queues:96:5
 ❯ async file:/.../node_modules/vitest/dist/chunk-vite-node-externalize.0094db73.js:10442:9
 ❯ async Vitest.runFiles file:/.../node_modules/vitest/dist/chunk-vite-node-externalize.0094db73.js:10452:12
 ❯ async Vitest.start file:/.../node_modules/vitest/dist/chunk-vite-node-externalize.0094db73.js:10379:5
 ❯ async startVitest file:/.../node_modules/vitest/dist/chunk-vite-node-externalize.0094db73.js:11102:5
 ❯ async start file:/.../node_modules/vitest/dist/cli.js:665:9
 ❯ async CAC.run file:/.../node_modules/vitest/dist/cli.js:661:3

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
Serialized Error: {
  "errors": [
    [Error: [birpc] timeout on calling "snapshotSaved"],
  ],
}
  • 一つづつモックを外してみたらglobal.Mathのモックが悪さをしていたっぽい

    vi.spyOn(global.Math, "random").mockReturnValue(0.123456789);
    
    • Math.random を使うのをやめ,cryptoによるランダム値の取得に変更
  • 実装自体を変更しています
    ※余談ですが、Math.random については SonarQube としても Security の項目でリスクがあると指摘しており、crypto でのランダム値取得が推奨されています。

個別に対応した移行ケース一覧

jest である Mock機能 が vitest にない

TypeError: vi.genMockFromModule is not a function
 ❯ src/__mocks__/leaflet.ts:6:24
      4| import L from 'leaflet';
      5|
      6| const LeafletMock = vi.genMockFromModule('leaflet')
       |                        ^
      7|
      8| class ControlMock extends LeafletMock.Control {
 ❯ async src/setup.ts:11:31

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
  • jest.createMockFromModule()/jest..getMockFromModule(moduleName) -> use vi.mock or vi.importMockに変わっている

  • more mock APIs

  • Jest

    const LeafletMock = vi.genMockFromModule("leaflet");
    
  • Vitest

    const LeafletMock = await vi.importMock("leaflet");
    

enzyme snapshot

  • Jest での Enzyme Snapshot テストを Vitest でそのまま実行すると exports['test > snapshot'] = 'ShallowWrapper {}'; となってしまう

  • どうやら jest.config.js で設定していたsnapshotSerializers: ['enzyme-to-json/serializer'],が効かなくなっている模様

  • 公式ドキュメント

  • 結論:enzyme-to-json を直接テストの中で使えばいける

  • 修正前

    it("snapshot", () => {
      // GIVEN
      // WHEN
      target = shallow(<Hoge />);
      // THEN
      expect(target).toMatchSnapshot();
    });
    
  • 修正後

    import toJson from "enzyme-to-json";
    it("snapshot", () => {
      // GIVEN
      // WHEN
      target = shallow(<Hoge />);
      // THEN
      expect(toJson(target)).toMatchSnapshot();
    });
    
  • describe でテストケースをグルーピングしており、 その下で snapshot を使用している場合、snap ファイルのケース名も移行前と若干変わっているので合わせる必要があります

    • そうしないと既存のテストケースと同じ snapshot でテストが検証してくれない
      • 修正前
        exports[`snapshot testcase`] = `
        
      • 修正後
        exports[`snapshot > testcase`] = `
        
    • snap の内容も若干変わっていたりするので確認しながら直す必要があります
      • 修正が必要な例 1(型が変わっている)
          -         onClick={[MockFunction]}
          +         onClick={[Function]}
        
      • 修正が必要な例 2(Object がいらない)
                    style={
          -           Object {
          +           {
                        \"display\": \"none\",
                      }
                    }
        

each + done callback パターン

each による繰り返しテストと、非同期の done テストを同時に適用したい

  • Jest
 it.each`
     args1  | args2 | expected
    ${false} | ${0} | ${''}
 `('jest each done test',
 ({ args1, args2, expected }, done: any) => {
  • Vitest
  it.each([
    [false,0,''],
  ])('vitest each done test',
  ( args1, args2, expected ) => new Promise<void>((done, reject) => {

location の mock 設定

  • Jest
    window.history.pushState({}, '', '/pages/hoge');

    • Jest でwindow.location.pathname = '/hoge'をしてもちゃんと反映されない
  • Vitest
    window.location.pathname = '/pages/hoge'

    • [追記:20230130 vitest:0.28.3]
      vi.stubGlobal('location', { pathname: '/pages/hoge' });

sonar-reporter にてRangeError: Maximum call stack size exceededが発生

  • "vitest-sonar-reporter": "^0.2.1"で解消されました
Test Files  388 passed (388)
     Tests  2067 passed (2067)
      Time  506.92s (in thread 234.93s, 215.78%)


⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Unhandled Error ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
RangeError: Maximum call stack size exceeded
 ❯ generateXml node_modules/vitest-sonar-reporter/dist/xml.js:58:12
 ❯ SonarReporter.onFinished node_modules/vitest-sonar-reporter/dist/sonar-reporter.js:37:35
 ❯ node_modules/vitest/dist/chunk-vite-node-externalize.6b27b039.mjs:10640:51
 ❯ Vitest.report node_modules/vitest/dist/chunk-vite-node-externalize.6b27b039.mjs:10638:38
 ❯ node_modules/vitest/dist/chunk-vite-node-externalize.6b27b039.mjs:10475:18
 ❯ processTicksAndRejections node:internal/process/task_queues:96:5
 ❯ Vitest.runFiles node_modules/vitest/dist/chunk-vite-node-externalize.6b27b039.mjs:10479:12
 ❯ Vitest.start node_modules/vitest/dist/chunk-vite-node-externalize.6b27b039.mjs:10406:5
 ❯ startVitest node_modules/vitest/dist/chunk-vite-node-externalize.6b27b039.mjs:11140:5

[追記:20230130 vitest:0.28.3] Request is not defined

  • 問題

    • Three.jsを使用しているコンポーネントのテストにてReferenceError: Request is not definedエラーが発生
  • 解決

[追記:20230214 vitest:0.28.3] coverageにて[JavaScript heap out of memory]

  • 問題

    • c8を使ったcoverageオプションを付与したテストコマンド実行時にmemory heap errorが発生
  • エラー

<--- Last few GCs --->

[2405:0x577a230]  1009664 ms: Scavenge (reduce) 4047.4 (4118.5) -> 4047.5 (4120.5) MB, 7.0 / 0.0 ms  (average mu = 0.245, current mu = 0.192) allocation failure 
[2405:0x577a230]  1009686 ms: Scavenge (reduce) 4052.4 (4124.0) -> 4052.4 (4124.5) MB, 7.7 / 0.0 ms  (average mu = 0.245, current mu = 0.192) allocation failure 
[2405:0x577a230]  1009728 ms: Scavenge (reduce) 4053.2 (4124.8) -> 4053.2 (4124.8) MB, 9.8 / 0.0 ms  (average mu = 0.245, current mu = 0.192) allocation failure 


<--- JS stacktrace --->

FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
 1: 0xb08e80 node::Abort() [node]
 2: 0xa1b70e  [node]
 3: 0xce1890 v8::Utils::ReportOOMFailure(v8::internal::Isolate*, char const*, bool) [node]
 4: 0xce1c37 v8::internal::V8::FatalProcessOutOfMemory(v8::internal::Isolate*, char const*, bool) [node]
 5: 0xe992a5  [node]
 6: 0xe99d86  [node]
 7: 0xea82ae  [node]
 8: 0xea8cf0 v8::internal::Heap::CollectGarbage(v8::internal::AllocationSpace, v8::internal::GarbageCollectionReason, v8::GCCallbackFlags) [node]
 9: 0xeabc6e v8::internal::Heap::AllocateRawWithRetryOrFailSlowPath(int, v8::internal::AllocationType, v8::internal::AllocationOrigin, v8::internal::AllocationAlignment) [node]
10: 0xe6d01f v8::internal::Factory::AllocateRawWithAllocationSite(v8::internal::Handle<v8::internal::Map>, v8::internal::AllocationType, v8::internal::Handle<v8::internal::AllocationSite>) [node]
11: 0xe7494c v8::internal::Factory::NewJSObjectFromMap(v8::internal::Handle<v8::internal::Map>, v8::internal::AllocationType, v8::internal::Handle<v8::internal::AllocationSite>) [node]
12: 0xe74f75 v8::internal::Factory::NewJSArrayWithUnverifiedElements(v8::internal::Handle<v8::internal::FixedArrayBase>, v8::internal::ElementsKind, int, v8::internal::AllocationType) [node]
13: 0xe751e2 v8::internal::Factory::NewJSArray(v8::internal::ElementsKind, int, int, v8::internal::ArrayStorageAllocationMode, v8::internal::AllocationType) [node]
14: 0xf851b1 v8::internal::JsonParser<unsigned short>::BuildJsonArray(v8::internal::JsonParser<unsigned short>::JsonContinuation const&, v8::base::SmallVector<v8::internal::Handle<v8::internal::Object>, 16ul> const&) [node]
15: 0xf8cdf4 v8::internal::JsonParser<unsigned short>::ParseJsonValue() [node]
16: 0xf8dbef v8::internal::JsonParser<unsigned short>::ParseJson() [node]
17: 0xd637bb v8::internal::Builtin_JsonParse(int, unsigned long*, v8::internal::Isolate*) [node]
18: 0x15d9cf9  [node]
Aborted (core dumped)
Error: Process completed with exit code 134.

まとめ

今回の検証で Jest から Vitest への色々なテストケースやライブラリ利用のケースでも移行ができることがわかりました。
主に移行時の苦しんで調べて回避してきた内容を書いてきましたが、Vitestへ 移行中のエンジニアの方々に役立てば幸いです。

68
38
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
68
38

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?