😽

Vitestの実行時間を8倍高速化:同一環境での実行によるパフォーマンス改善

に公開

はじめに

Vitest はデフォルトの設定では、テストファイルごとに分離された環境を使用してテストを並列実行します。
この設定はグローバルな状態や副作用に依存した実装やライブラリなどを含むテストを並列実行するために有効です。

しかし、この設定は各テストファイルごとにテスト環境の起動や破棄を行うためテスト実行時間が長くなります。

この記事では React アプリケーションのテストコードを、環境の分離設定をやめて同一環境で実行することにより、実行速度を向上させた方法とそれに伴って発生した問題の修正方法についてご紹介いたします。

背景

弊社は microCMS というヘッドレス CMS を開発しており、その管理画面は React で開発されています。
大まかな構成としては、Vite を使用した SPA で、テストコードは testing-library/react を使用したコンポーネントテスト(スナップショットテストと user-event を使用したテスト)や関数の単体テストが主となっています。

今回の改善を行う前は、約 1600 のテストに対して 10 分ほどかかっていました。その後、シャーディングによる並列化で 5 分割してテストを行なっておりましたがそれでも 4 分ほどかかっていました。
また、GitHub Actions の料金体系として、ジョブなどを並列化した際にそれぞれの実行時間が加算されるため、並列化すればそれで何の問題もなしというわけにもいきません。

そして今回の改善後には、シャーディングによる並列化なしで 70 秒ほどに短縮され、約 8 倍の高速化に成功しました。

パフォーマンス改善の効果


項目 改善前 改善後 改善率
テスト実行時間 575 秒 70 秒 約 88%削減
CI ジョブの総実行時間 約 15 分 約 1.5 分 約 90%削減

環境の分離をオフにする

Vitest で環境の分離をオフにする最もシンプルな方法はisolateオプションを false に設定することです。
しかし、先述した通り Vitest はテストファイルを並列で実行するため、同一環境だとテスト同士が影響して問題が発生する可能性が高くなります。
こうしたテスト同士の影響を避けるため、環境の分離をオフにはしつつ逐次実行でテストを実行するように設定しました。

設定方法

テスト実行時のコマンドを以下のように変更します。

vitest run --pool threads --poolOptions.threads.singleThread

また、vitest.config のファイル側に設定を追加する方法もありますが、基本的にはおすすめしません。
環境の分離をオフにする設定は watch モードで問題があり、普段の開発時には適さない方法だからです。
そのため CI で実行するためのコマンドに適用するため CLI オプションを使用します。

発生した問題

おそらく上記のコマンドでテストを実行すると今まで成功していたテストのいくつかは失敗することがあるかと思います。
microCMS のテストコードでは主に以下の問題が発生しました。
(これら以外にも mockClear が適切に行われていなかったり、モックのやり方に不備があったテストなどが失敗しましたが地道に修正するのみなのでここでは触れません)

  1. testing-library/react の cleanup が行われず前のテストでのレンダリング結果が次のテストに影響する
  2. i18next の言語設定の変更が次のテストに影響する
  3. React.useId を使用しているコンポーネントのスナップショットテストの結果がテスト実行順序によって変わる
  4. axe-core を使用したテストが失敗する

testing-library/react の cleanup が行われず前のテストでのレンダリング結果が次のテストに影響する

この Issue が関係していそうでした。
https://github.com/vitest-dev/vitest/issues/1430

本来は以下のように globals を true にしていれば、testing-library/react が自動的に cleanup を行ってくれるはずなのですが、環境の分離をオフにすると cleanup が行われないようでした。

セットアップファイルにて afterEach を設定することで解決できます。

setup.ts
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';

afterEach(cleanup);

i18next の言語設定の変更が次のテストに影響する

microCMS では以下のように英語と日本語を切り替えてスナップショットテストを行っているコンポーネントがあります。

import { render } from "@testing-library/react";
import { describe, expect, test } from "vitest";

import i18n from "@/i18n";

import { Range } from "..";

describe("Range", () => {
  test.each(["ja", "en"])("Snap Shot", (lang) => {
    i18n.changeLanguage(lang);
    const { container } = render(<Range />);
    expect(container).toMatchSnapshot();
  });
});

テストごとにi18n.changeLanguage(lang);を実行しているため、このテスト後は言語設定が英語になっているはずです。
しかし、この次に実行されるテストで日本語を期待しているものが実行されると失敗します。

これが原因で落ちているテストにi18n.changeLanguage('ja')を追加することで解決しました。

React.useId を使用しているコンポーネントのスナップショットテストの結果がテスト実行順序によって変わる

useIdグローバル変数を使用して一意な ID を生成するため、同一環境でテストを実行するとテストの実行順序によってコンポーネント内の HTML 要素の id 属性が変わってしまい、スナップショットテストの結果が変わってしまいます。

これを解決するための方法として、useId をモックして毎回同じ ID を返すようにしました。

setup.ts
import { vi } from 'vitest';

vi.mock(import('react'), async (importOriginal) => {
  const actual = await importOriginal();
  return {
    ...actual,
    useId: () => 'r:id',
  };
});

microCMS では RadixUI を採用しているため、RadixUI 内部で利用されている@radix-ui/react-idについてもモックする必要がありますが、React.useId と同じようにはモックができません。
これは Vite の特徴の 1 つである依存関係を事前バンドルすることの影響で、内部パッケージをモックすることができないためです。

以下の記事にて解決策が記載されています。

https://zenn.dev/rizzzse/articles/56a33a77baf512

RadixUI のパッケージをモックするためには以下のように設定します。

vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
  test: {
    server: {
      deps: {
        inline: ['radix-ui', /@radix-ui\/.*/],
      },
    },
  },
});
setup.ts
import { vi } from 'vitest';

vi.mock('@radix-ui/react-id', () => {
  return {
    useId: () => 'r:id',
  };
});

これでテストを実行すると、useId は毎回同じ ID を返すようになり、スナップショットテストの結果がテスト実行順序によって変わることがなくなります。

axe-core を使用したテストが失敗する

アクセシビリティチェックの一環として axe-core(vitest-axe)を使用していますが、そのテストが失敗するようになりました。
これは以下の Issue が関係していると思われます。
https://github.com/dequelabs/axe-core/issues/3426

ただし、今回の方法では同一環境内で逐次実行をしており並列実行ではないため、なぜこの問題が生じるのか詳細についてはわかりませんでした。
axe を使用したテストのうち、いずれかのテストがタイムアウトしたのち、それ以降のテストではAxe is already runningのエラーになってしまう現象で、このコメントにある通りテストの失敗後に「完了」としてマークされず、次の実行時に並列に実行されていると認識されてしまうのだと思われます。

今回の Vitest パフォーマンス向上ではこの問題に対しては本質的改善はせずに CI 上ではスキップし、任意で実行できるようにテストを調整することとしました。

test.runIf を使用して、VITEST_ENABLE_A11Y_CHECKを true に設定した時のみ実行するようにしました。

import { render } from "@testing-library/react";
import { describe, expect, test } from "vitest";
import { axe } from "vitest-axe";

import { Range } from "..";

const enableA11yCheck = process.env.VITEST_ENABLE_A11Y_CHECK === "true";

describe("Range", () => {
  test.runIf(enableA11yCheck)("a11y check", async () => {
    const { container } = render(<Range />);
    expect(await axe(container)).toHaveNoViolations();
  });
});

axe-core のテストを実行するためには次のようなコマンドを実行します。

VITEST_ENABLE_A11Y_CHECK=true vitest run --testNamePattern 'a11y check'

まとめ

今回の改善では、Vitest の環境の分離をやめて同一環境で逐次実行することにより、テスト実行時間を約 8 倍高速化することに成功しました。
Vitest のパフォーマンスを向上する方法は、他にもバレルファイルをやめたり、test.concurrentを使用した並列化や、deps.optimizer.webを使用した依存関係の最適化などがありますが、今回のように環境の分離をオフにすることで劇的にパフォーマンスを改善できることがあります。

今後の展望としては、環境の分離をオフにしたまま並列実行ができるようにテストを調整することで、よりパフォーマンスを向上できればと思っています。
また、collect というファイル収集にかなりの割合の時間がかかっていることからバレルファイルをやめることも検討しています。

CI が早いことは開発の生産性に深く関わりがあると思いますので、今回の記事の内容が 1 つでもお役に立てていれば幸いです。

参考リンク

Vitest 関連

React 関連

テスト関連

CI/CD 関連

株式会社microCMS

Discussion