こんにちは、普段 pixivcoban のフロントエンド開発を担当している nyamadan です。
今回は、私たちのプロジェクト「pixivcoban」で、テストフレームワークを長年利用してきたJestからVitestへと移行した事例についてお話ししたいと思います。pixivcobanでは、より良い開発体験とプロダクト品質の向上を目指して、積極的に開発環境のアップデートに取り組んでいます 。この記事が、同じようにJestからの移行を検討されているフロントエンドエンジニアの皆さんの、少しでもお役に立てれば幸いです。
移行前のpixivcobanのテスト環境は、Next.jsをベースにJest
、testing-library
、そしてjest-environment-jsdom
を利用していました 。
Jestが抱えていた課題
便利に使っていたJestですが、プロジェクトが成長するにつれていくつかの課題も顕在化してきました。
- パフォーマンスの問題:テストの実行速度に関して、パフォーマンス上の課題を感じていました 。
- ESM専用ライブラリへの対応の難しさ: 最近ではESM(ECMAScript Modules)のみをサポートするライブラリも増えてきました。私たちが導入したライブラリもESM専用のものがいくつかあったため、Jestでのテスト実行が困難になるという事態に直面しました 。これに対応するため、該当ライブラリをモックしたり、場合によってはテスト自体を諦めざるを得ないこともありました 。
- テストカバレッジの低下: pixivcobanではテストカバレッジを重視しているのですが、ESMのライブラリが読み込めないことにより、カバレッジが意図せず低下してしまうという問題も発生していました 。
Vitest移行の決め手
これらの課題を解消するために、いくつかの選択肢を検討した結果、Vitestへの移行を決断しました。その主な理由は以下の通りです。
- 社内での豊富な採用実績: 幸いなことに、社内には既にVitestを導入しているプロジェクトが複数あり、知見やノウハウが共有されやすい環境がありました 。
- テストフレームワークの統一への期待: pixivcobanの管理画面では先行してVitestを採用していたため、本体と管理画面でテストフレームワークを統一することで、開発体験の向上やメンテナンスコストの削減に繋がるのでは、というモチベーションもありました 。
移行戦略と準備
移行対象のテストケースは約500ほどありました 。これらを段階的に進めるか、一気に移行するかを検討した結果、ちょうど大規模な開発案件が進行していないタイミングがあったため、その機会を捉えて一括で移行する方針としました 。
具体的な設定方法については、Next.jsの公式ドキュメントにVitest向けの設定例が掲載されていたので、こちらを参考に進めました。
また、JestからVitestへのコード変換を効率化するために、codemod を私たちのプロジェクト向けにフォークして利用することにしました。
具体的な移行手順とハマりどころ
実際の移行作業は、主に以下のステップで進めていきました。
依存関係の整理
まず、package.json からJest関連のライブラリを削除し、代わりにVitestおよび関連するライブラリを追加します 。TypeScriptを利用している私たちのプロジェクトでは、公式ドキュメントに従い次のようなライブラリを追加しました。
npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/dom vite-tsconfig-paths
jscodeshiftによる機械的な書き換え
次に、jscodeshift を使って、既存のJestのテストコードをVitestの記法へと機械的に変換しました 。これにより、多くのテストコードがある程度自動で書き換えられ、テストが実行できる状態を目指しました。
残ってしまったJest特有の型定義を修正
オープンソースのcodemodだけでは対応しきれず、一部Jest特有の型定義がコード中に残ってしまう箇所がありました。例えば
jest.SpyInstance
といった型です。これらは、以下のようなjscodeshift
のカスタムスクリプトを作成して、VitestのMockInstance
型に置換しました 。
/** * jest型をvitest型に書き換える * * let x: jest.SpyInstance // before * let x: MockInstance // after **/ const replaceJestTypes = (root, j) => { root .find(j.TSTypeReference, { typeName: { type: 'TSQualifiedName', left: { type: 'Identifier', name: 'jest' }, }, }) .filter((x) => { if (x.value.typeName.type !== "TSQualifiedName") { return false; } if (x.value.typeName.right.type !== "Identifier") { return false; } return x.value.typeName.right.name === "SpyInstance"; }) .replaceWith((x) => { return j.tsTypeReference(j.identifier("MockInstance")); }); };
next/image
などのモック対応Next.js特有のコンポーネントもいくつかモックで置き換える必要がありました。代表的な例として
next/image
コンポーネントのモックがあります。私たちは以下のように、next/image
を単純な<img>
タグに置き換える形でモックを再実装しました 。
// next/image を単なるimgに置換する vi.mock("next/image", () => ({ default: forwardRef((props, ref) => { const propsWithRef = { ...props, ref }; return <img {...propsWithRef} />; }), }));
@testing-library/react
のcleanup問題へのワークアラウンド移行作業中に、
@testing-library/react
のcleanup
が自動で呼ばれない場面に遭遇しました(Issue #1197)。これに対しては、各テストの実行後に明示的にcleanup()
を呼び出すことで対応しました 。
import { cleanup } from "@testing-library/react"; afterEach(() => { // @testing-library/reactのcleanupを行う cleanup(); });
ここまででVitestの設定周りの大きな修正は一通り完了し、残りは個別のテストファイルの内容を修正していくフェーズに移りました 。
unmockの巻き上げ
以下のテストコードを見てください。このコードはJestで書かれており、fs.readFile
メソッドをモックしてテストを実行しています。
このコードではafterAll
を使用してモック処理を元に戻しています。Jestではテストファイルごとにモジュールが分離されているためunmock
は不要なのですが、念のためにということで記述していて、これが混乱を呼ぶこととなりました。
import * as fs from "node:fs/promises"; jest.mock("node:fs/promises", () => { return { // readFileメソッドをモック async readFile() { return "Hello World!"; }, }; }); test("read file test", async () => { const content = await fs.readFile("not_exists.txt", { encoding: "utf8" }); expect(content).toBe("Hello World!"); }); afterAll(() => { jest.unmock("node:fs/promises"); });
これをcodemodを使用して次のようなvitestコードに変換するとテストが通らなくなってしまいました。不思議なことにvi.unmock
をコメントアウトすることでこのテストは通るようになります。
import { afterAll, expect, test, vi } from "vitest"; import * as fs from "node:fs/promises"; vi.mock("node:fs/promises", () => { return { // readFileメソッドをモック async readFile() { return "Hello World!"; }, }; }); test("read file test", async () => { // Error: ENOENT: no such file or directory, open 'not_exists.txt' const content = await fs.readFile("not_exists.txt", { encoding: "utf8" }); expect(content).toBe("Hello World!"); }); afterAll(() => { // vi.unmockをコメントアウトするとテストが通る。 vi.unmock("node:fs/promises"); });
これは何故かというとvitestが次のようにunmock
の処理をmock
直下に移動してしまうためで、afterAll
で unmock
が実行されなくなっているためです。
vi.mock("node:fs/promises", () => { return { // readFileメソッドをモック async readFile() { return "Hello World!"; } }; }); vi.unmock("node:fs/promises"); // vi.unmockがここに移動している const __vi_import_0__ = await import("node:fs/promises"); // dynamic importに書き換わっている import { afterAll, expect, test, vi } from "vitest"; test("read file test", async () => { const content = await __vi_import_0__.readFile("hello.txt", { encoding: "utf8" }); expect(content).toBe("Hello World!"); }); afterAll(() => { // vi.unmockが消えている });
今回の場合は unmock
は削除するだけで十分でしたが、こういったモックの巻き上げ挙動は覚えておく必要があると感じました。
パフォーマンスについて
当初期待していたパフォーマンスの向上については、複数回テストを実行して検証しましたが、顕著な違いは観察されませんでした。パフォーマンスのボトルネックは、JestやVitestではなく、他の要因にあると考えています。
移行作業中の工夫と学び
移行作業は、日々の開発業務と並行して進める必要がありました。その中で、特に以下の点に工夫を凝らしました。
- 進行中の開発とのうまい付き合い方: 移行作業中もフロントエンドの開発は止まりません。そのため、codemodを一度適用したテストファイルを直接編集していくのではなく、codemodを通す前のテストコードを修正していくという方針を取りました 。ベースブランチのテストコードに変更があった際には、都度作業ブランチにrebaseを実施し、再度codemodを実行するという流れで、コンフリクトを最小限に抑えつつ作業を進めました 。
- JestとVitestでのモックの挙動の違い: Jestでは問題なく動作していたテストがVitestで失敗するケースの多くは、モックの挙動の違いに起因するものでした 。これらは一つ一つVitestの実装に合わせて修正していく必要がありました。
unmock
の巻き上げという興味深い現象: JestとVitestではモジュールのモックやアンモックの取り扱い方が異なるため、この挙動の違いを理解するのに少し時間がかかりました。
まとめ
今回はpixivcobanのフロントエンドテスト環境をJestからVitestへ移行した事例をご紹介しました。ESMへのスムーズな対応、そして管理画面とのテストフレームワーク統一といった当初の目的を達成し、より快適な開発環境を手にすることができたと感じています。
この記事が、Jest から Vitest への移行を考えている方々の少しでもお役に立てれば幸いです。