前回の記事 では Vite に移行しましたが、テストは Jest のままでした。今回は Vitest に移行した際のログです。

ライブラリのインストール

happy-dom だと通らなくなるテストがいくつかありました。 screen.getByRole などの取得ができないなどがあり、今回は jsdom で進めることにしました。

yarn add -D vitest jsdom

package.json の scripts を変更しておきます。

     "start": "vite",
     "build": "tsc && vite build",
     "preview": "vite preview",
-    "test": "jest",
-    "test-coverage": "jest --coverage --watchAll=false",
+    "test": "vitest",
+    "test-coverage": "vitest --coverage",
     "compile": "tsc",
     "lint": "eslint src/**/*.ts src/**/*.tsx --quiet && prettier --check .",
     "verify-code": "yarn compile && yarn lint",

設定

vitest.config.ts を作成:

import { defineConfig, mergeConfig } from "vitest/config";
import viteConfig from "./vite.config";

export default mergeConfig(
  viteConfig,
  defineConfig({
    test: {
      globals: true,
      environment: "jsdom",
    },
  })
);

globals: true を設定すると、テストコードで describe, it, vi などを import せず使えるようになります。これらの型定義を使えるようにするため tsconfig にも設定が必要です。

tsconfig.json を修正:

     "strict": true,
     //"noUnusedLocals": true,
     //"noUnusedParameters": true,
-    "noFallthroughCasesInSwitch": true
+    "noFallthroughCasesInSwitch": true,
+
+    /* Vitest */
+    "types": ["vitest/globals"]
   },
   "include": ["src", "graphql/codegen.ts"],
   "references": [{ "path": "./tsconfig.node.json" }],

また、 vitest.config.ts が型チェックに含まれるようにします。

tsconfig.node.json を修正:

     "moduleResolution": "bundler",
     "allowSyntheticDefaultImports": true
   },
-  "include": ["vite.config.ts", ".eslintrc.cjs"]
+  "include": ["vite.config.ts", "vitest.config.ts", ".eslintrc.cjs"]
 }

testPathIgnorePatterns を exclude に移行

Jest 使用時は以下のように testPathIgnorePatterns を正規表現で指定して、ファイル名が _ で始まるファイルを除外していました。

{
  "testPathIgnorePatterns": ["__tests__/_(.)+.ts(x)?$"]
}

Vitest の exclude は glob pattern を指定します。また defaultExclude を含めるようにします。

-import { defineConfig, mergeConfig } from 'vitest/config';
+import { defineConfig, mergeConfig, defaultExclude } from 'vitest/config';
 import viteConfig from './vite.config';

 export default mergeConfig(
   viteConfig,
   defineConfig({
     test: {
       globals: true,
+      exclude: [...defaultExclude, '**/__tests__/_*.ts', '**/__tests__/_*.tsx'],

テストエラー対応

エラー Invalid Chai property: toHaveTextContent

toHaveTextContent の呼び出しでエラーになりいました。 これは @testing-library/jest-dom の提供するメソッドです。 setupTests.ts に import を追加し、このファイルをロードするよう設定します。

src/setupTests.ts を作成:

import "@testing-library/jest-dom";

vitest.test.config を修正:

     test: {
       globals: true,
       exclude: [...defaultExclude, '**/__tests__/_*.ts', '**/__tests__/_*.tsx'],
       environment: 'happy-dom',
+      setupFiles: ['./src/setupTests.ts'],
     },
   }),
 );

クラス名が scoped のものになっていてセレクタで取れずアサーション失敗

このアプリケーションでは module css (scss) を使用しています。 Jest で実行していた際は元のクラス名で render されていたのが、 scoped なクラス名になっていました。

-      <div class="Info" >
+      <div class="_Info_78db8b" >

これをテストで expect(container.querySelector(".Info")).toHavTextContent(...) としていて、取得できずエラーになりました。

Jest を使っていた時は identity-obj-proxy を使ってこの問題に対処していました。 Vitest では以下の設定で対処できます。

       globals: true,
       exclude: [...defaultExclude, '**/__tests__/_*.ts', '**/__tests__/_*.tsx'],
       environment: 'jsdom',
       setupFiles: ['./src/setupTests.ts'],
+      css: {
+        modules: {
+          classNameStrategy: 'non-scoped',
+        },
+      },
     },
   }),
 );

jest-global-setup.cjs を移行

jest-global-setup.cjs で dotenv のロードなどをしていたので移行します。 TypeScript ファイルに変更し、 vitest.config.ts でこのファイルをロードするようにします。

       globals: true,
       exclude: [...defaultExclude, '**/__tests__/_*.ts', '**/__tests__/_*.tsx'],
       environment: 'jsdom',
       setupFiles: ['./src/setupTests.ts'],
+      globalSetup: ['./src/vitestGlobalSetup.ts'],
       css: {
         modules: {
           classNameStrategy: 'non-scoped',

expected “spy” to not be called at all, but actually been called 1 times

特定のケースでモックのアサーションが失敗していました。

AssertionError: expected "spy" to not be called at all, but actually been called 1 times

Received:
  1st spy call:
    Array []

Number of calls: 1

モックのリセットをしていないことが原因でした。

vitest.config.ts を修正:

      globalSetup: ['./src/vitestGlobalSetup.ts'],
      css: {
        modules: {
           classNameStrategy: 'non-scoped',
         },
       },
+      mockReset: true,
    },
  }),
);

Vitest テスト実行がハングアップする問題

テストがハングアップする問題が起きました。具体的には、コマンドを実行して特定のテストで止まり、そのまま応答がなくなるもののプロセスは動き続けています。

setupTests.ts にモックを追加していたのですが、この定義方法に問題がありました。

vi.mock('react-i18next', () => ({
  useTranslation: () => ({
    t: (key: string) => key,
  }),
});

useTranslation の戻り値のプロパティ t がコード内で useCallback の依存配列に含まれているとハングアップが発生します。この定義方法だと t が毎回新しい関数オブジェクトになるため、 useCallback が無限ループのような状態になっていたものと思われます。

以下のように t をトップレベルの変数に変更すると解消しました。

const t = (key: string) => key;
vi.mock('react-i18next', () => ({
  useTranslation: () => ({
    t,
  }),
});

ちなみに vi.mock はファイル上部に移動されます (巻き上げ)。変数定義を vi.mock よりも前にしたい場合 vi.hoisted を使います。

const t =  vi.hoisted((key: string) => key);
vi.mock('react-i18next', () => ({
  useTranslation: () => ({
    t,
  }),
});

テストコード修正

jest 関数を置換していきます。

  • jest.fn => vi.fn
  • jest.mock => vi.mock
  • jest.requireActual => await vi.importActual
  • jest.spyOn => vi.spyOn

vi.importActual

vi.importActual は Promise を返すため以下のような変更が必要でした。

@@ -33,8 +33,8 @@ import {
 const mockedNavigate = vi.fn();
-jest.mock('react-router-dom', () => ({
-  ...jest.requireActual('react-router-dom'),
+vi.mock('react-router-dom', async () => ({
+  ...(await vi.importActual('react-router-dom')),
   useNavigate: () => mockedNavigate,
 }));

requireActual で複数行にわたるものがなかったため正規表現で一括置換できました。 (ただし外側の関数に async をつけるのは手動)

  • pattern: jest\.requireActual\('(.*?)'\)
  • replace: (await vi.importActual('$1'))

spyOn, mockImplementation

mockImplementation に何も渡していないコードがあり、 jest ではこの場合元の処理が使われます。つまりアサーションでコール回数を数えるためだけのために spyOn を使っているということです。

今回は単に削除で対応できました。

-  const mocked = vi.spyOn(MyClass, 'method').mockImplementation();
+  const mocked = vi.spyOn(MyClass, 'method');

型エラー vi.fn<void, []>()

-vi.fn<void, []>()
+vi.fn<[], []>()

close timed out after 10000ms

テスト実行の終了後にこのメッセージが表示されて、プロセスが終了しない状態になりました。

close timed out after 10000ms Failed to terminate worker while running <snip>.test.tsx. See https://vitest.dev/guide/common-errors.html#failed-to-terminate-worker for troubleshooting. Tests closed successfully but something prevents Vite server from exiting You can try to identify the cause by enabling “hanging-process” reporter. See https://vitest.dev/config/#reporters

ドキュメントの通り vitest.config.tspool: 'forks' を設定して解消しました。

      css: {
        modules: {
          classNameStrategy: 'non-scoped',
        },
      },
      mockReset: true,
+     pool: 'forks',
    },

移行完了

最後に Jest で使っていた設定ファイル、パッケージを削除して完了です。

rm babel.config.json
yarn remove babel-preset-vite identity-obj-proxy

ここまでの修正でほぼ全てのテストが通るようになりました。

Jest を使っていた頃と比べると、テストの実行速度が上がっておよそ半分の時間で実行できるようになりました。 Node v18 から v20 へのアップデートでさらに半分になり、当初の 4 倍速くなりました。