Movatterモバイル変換


[0]ホーム

URL:


Zenn
shingo.sasakishingo.sasaki
🌟

Vite は使ってないけど Jest を Vitest に移行する

に公開

概要

本記事は、SmartHR Advent Calendar 2023 シリーズ2 の11日目です。

今回は、ReactWebpack で開発している Web アプリケーションのフロントエンドテストフレームワークをJest からVitest に移行した理由と、その具体的な作業内容についてまとめました。

バージョン情報

  • Vite v5.0.6
  • Vitest v1.0.2
  • jest v29.7.0
  • typescript v5.3.3
  • webpack v5.89.0
  • Node.js v20.10.0
  • yarn v1.22.19
  • macOS Ventura 13.5.2

移行結果

手っ取り早く最初に移行結果をまとめました。
(正確には移行以外の改善による変化も含むので参考程度にしてください)

項目JestVitest備考
ローカル起動時間2秒程度0.6秒程度非常に簡素なテストコード一種の実行時間
ローカル実行時間35秒程度20秒程度すべてのテスト実行時間
CI実行時間90秒程度55秒程度すべてのテストの実行時間
依存パッケージ数63プラグインパッケージ含む

上記以外で特筆すべき点として、他の開発者(≒チームメンバー)にとっては、変更の影響をほとんど受けずに、ノーコストで上記恩恵を受けられる点があります。

これはVitestJest に対する高い互換性のおかげでテストコードの書き方に大きな変更がなかったことと、テスト実行コマンドをnpm-scripts によって隠蔽していたことによるもので、移行したことに気づきさえしない可能性もあります。

Vite を使ってないのにVitest 使ってええんか?

今回Jest からVitest への移行を行ったプロジェクトは、開発サーバーやプロダクションビルドにはWebpack を使用しており、Vite は一切使用していませんでした。

そういったプロジェクトにおいても、Vite をベースとしたテストフレームワークであるVitest は使用して良いものでしょうか?

これについてはVitest のドキュメント内にて、以下のように言及されています。

https://vitest.dev/guide/comparisons.html#jest

Even if your library is not using Vite (for example, if it is built with esbuild or Rollup), Vitest is an interesting option as it gives you a faster run for your unit tests and a jump in DX thanks to the default watch mode using Vite instant Hot Module Reload (HMR). Vitest offers compatibility with most of the Jest API and ecosystem libraries, so in most projects, it should be a drop-in replacement for Jest.

VitestVite を用いた開発・プロダクションを行っている場合に、共通の設定・エコシステムをテストフレームにも適用できることが強みなのは事実です。

しかし、そうでなくテストフレームワークのためだけにVite を導入することになっても、上記ドキュメントで言及されている通りの大きなメリットを得られると思います。

また、VitestdependenciesVite に依存していることから、Vite を直接インストールする必要もありませんし、設定ファイルをvite.config.ts でなくvitest.config.ts に記述することもできることから、Vitest 自体もVite の存在をあまり意識せずに使えるように作られていると言えます。

https://github.com/vitest-dev/vitest/blob/7006bb367494536e2ecf762a5636e509734e43e5/packages/vitest/package.json#L158

そういった背景から、今回は特に悩むこと無くVitest を導入することを決めました。

なぜJest だと辛かったのか

Jest は既に10年以上開発が続けられている、JavaScript テストフレームワーク界の重鎮にして、デファクトスタンダートにも近い存在です。
https://jestjs.io/ja/

テストランナー、アサーション、モック、カバレッジレポート、スナップショットといった、テストに関わる有用な機能を一通り網羅し、オールインワンかつゼロコンフィグであることを売りとしています。

私自身も過去に、Jest 以前の時代の様々なテストツールに辟易し、Jest に一本化することで全てを解決できました。

一方で、Node.jsWeb,TypeScript,React といった、フロントエンド開発の基礎となる技術要素は日々進化を重ねています。

それらの変化に対して、Jest 側もサポートの範囲を広げたり、豊富なエコシステムにより解決されたりを繰り返しています。

例えばTypeScript を使用するためにはts-jest を別途導入し、コード変換のルールを設定ファイルに記述する必要がありますし、ESMJest で動かすのはまだまだ課題がありそうです。

そのような経緯から、Jest を長らく使い続けていると、職人技や歴史的経緯がプロジェクト上に現れてしまい、何か触れてはいけないもののようになってしまいます。

!

もちろん、適切にJest を理解し、最新情報のキャッチアップと追従が出来ている場合には、Jest でも大抵のユースケースを満たすことは可能です。

ここではフロントエンドにあまり詳しくない人の視点での複雑さを課題としています。

なぜVitest を選んだのか

Vitest は以下のような特性を持ちます。(多くはVite の特性でもあります)

  • esbuild を用いた高速なビルド
  • HMR のように、コード変更に影響するテストのみを再実行する機能
  • ESM,TypeScript,JSX のビルトインサポート
  • Jest との高い互換性による移行容易性

前述の、なぜ Jest だと辛かったのか のポイントを解決するのにも十分であることがわかります。

そして何より、つい先日、満を持してVitest は v1.0.0 がリリースされました。

https://github.com/vitest-dev/vitest/releases/tag/v1.0.0

それまではVitest 自身もマイナーバージョンで頻繁に破壊的変更が入りましたが、v1.0.0 となってしまえば安定するはずなので、このビッグウェーブに乗るしかないでしょう。

移行作業

ここからは、Jest からVitest への具体的な移行作業と、各所でのハマったポイント、トラブルシューティングを時系列で記載します。

!

移行作業はプロジェクトの構成や特性によって大きく変わることがあります。

すべてのプロジェクトでこの手順で解決できたり、同様の問題が発生するわけではありませんのでご注意ください。

Vitest のインストール

Vitest を依存に追加すれば自動でVite もインストールされるので、個別インストールは不要です。

$yarnadd-D vitest

設定ファイルを作成

Vitest の設定はVite の設定ファイルであるvite.config.ts に記述することも出来ますが、Vitest 単体の場合はvitest.config.ts も使用できます。

今回は、vitest.config.ts を使用することで、ファイル名から「Vitest は使うがVite を直接使っているわけではない」ということを認知しやすくします。

vitest.config.ts
import{ defineConfig}from'vitest/config'exportdefaultdefineConfig({  test:{// ここに Vitest 用の設定を書き込んでいく},})

ファイル名は違えど、実態はVite の設定ファイルと同じであるため、Vitest 用の設定はtest フィールドに記述します。

グローバルAPI を有効化する

globals は、describetest,beforeEach といったテスト用の API を、テストコード内でimport することなく使えるようにする設定です。

Jest ではこれがデフォルトで有効化されていましたが、Vitest の場合は有効化が必要です。

vitest.config.ts
import{ defineConfig}from'vitest/config'exportdefaultdefineConfig({  test:{    globals:true},})

グローバルAPIの多くはJest と互換性を持っているため、これだけで既存のテストコードの多くは動くようになります。

グローバルAPI の型解決をする

前述のglobals: true によって、テストコード上でdescribe などのグローバルAPIが利用できるようになりました。

しかし、これだけだとランタイムでグローバルAPIが自動で読み込まれるだけなので、TypeScript を使用している場合は型レベルでも自動で読み込む必要があります。

基本的には以下のようにcompilerOptions.types フィールドにグローバルAPIの型ファイルを指定することで、ソースコード上でのimport が無い型でもここから読み込めるようになります。

tsconfig.json
// 一部抜粋{"compilerOptions":{"types":["vitest/globals"]}}

が、ここで以下のようなエラーが発生しました。

node_modules/vite/dist/node/index.d.ts:6:41 - error TS2307: Cannotfind module'rollup/parseAst' or its correspondingtype declarations.6export{ parseAst, parseAstAsync} from'rollup/parseAst';

結論だけ書くと、tsconfig.jsoncompilerOptions.moduleResolutionNode またはNode10 になっている場合、Node16NodeNext あるいはBundler に設定する必要がありました。

今回は既存コードの都合、Node16 では不十分であったため、Bundler に設定しました。

tsconfig.json
// 一部抜粋{"compilerOptions":{"moduleResolution":"bundler","types":["vitest/globals"]}}

結論だけ書きましたが、小難しい調査メモは以下スクラップに記載し、ここでは割愛します。
https://zenn.dev/sa2knight/scraps/636bedb1f9b019

パスエイリアスを反映させる

ここでいうパスエイリアスは、tsconfig.json におけるcompilerOptions.paths フィールドや、webpack.config.js における resolve.alias にあたる設定で、import 時のパスに対して読み書きしやすいショートカット用途のエイリアスを付与する機能です。

プロジェクトで使用しているエイリアスをVitest にも読み込ませるためには、resolve.alias を設定します。エイリアスはモジュール解決のためのVite 側の設定のため、test フィールド内ではないことにご注意ください。

vitest.config.ts
exportdefaultdefineConfig({  resolve:{    alias:{'@/':'/src/client',}}  test:{// こっちじゃないので注意}})

これによって、@/hogehoge というパスが、/src/client/hogehoge と読み替えられます。

しかし、既にtsconfig.json 側でも同様の設定をしている場合は DRY にしたいでしょう。そこで、vite-tsconfig-paths というプラグインを導入しました。
https://github.com/aleclarson/vite-tsconfig-paths

これをインストールし、Vite に読み込ませるだけで、tsconfig.json 内のcompilerOptions.paths オプションをvitest.config.tsresolve.alias に読み込めるようになりました。

$yarnadd-D vite-tsconfig-paths
vitest.config.ts
exportdefaultdefineConfig({  plugins:[tsconfigPaths()],  test:{// こっちじゃないので注意}})

ブラウザAPI を利用できるようにする

あるテストコードを実行する際に以下のエラーが出ました。

ReferenceError: document is not defined

documentwindownavigator などに読み替えても構いません。要はNode.js 上には定義されていないブラウザAPIに依存したコードを実行した際に生じるエラーです。

Web アプリケーションを開発する上では、ブラウザAPIを利用したコードを書き、そのテストコードも書くのは一般的です。

しかし、テストコードはNode.js (などブラウザ以外の実行環境)上で実行するため、これらの API が呼び出せません。

そこで、jsdom のようなブラウザAPI互換を持った仮想環境を用意し、その中でテストコードを実行させることでブラウザAPIもシミュレートできるようにします。

Jest 時代は定番のjsdom を使用していましたが、今回はさらにパフォーマンスが優れているとされるhappy-dom を使用します。

$yarnadd-D happy-dom

Vitesthappy-dom を使用する場合は、environment オプションを設定します。

vitest.config.ts
exportdefaultdefineConfig({  test:{    environment:'happy-dom',},})

これでブラウザAPIを使用したテストコードも実行できるようになりました。

モック系のAPIを差し替える

jest.fn() や、jest.spyOn など、Jest ネームスペース以下のメソッドを使用している箇所を、機械的にvi.fn(),vi.spyOn と置換していきます。

多くの API はJest 互換があるため単純な置換だけで解決しますが、一部は以下のように異なる呼び出し方が必要になる場合もあるようです。

- jest.setTimeout(5_000)+ vi.setConfig({ testTimeout: 5_000 })

テストのスキップ方法を差し替える

Jest では以下のようにxtestxdescribexit を用いてテストのスキップが出来ていました。

xdescribe('スキップするテストスイート',()=>{xit('スキップするテスト',()=>{})xtest('スキップするテスト',()=>{})})

これらはdescribe.skipit.skipxtest.skip のエイリアスでしたが、Vitest ではエイリアスが無くなったようなので、以下のように差し替えます。

describe.skip('スキップするテストスイート',()=>{  it.skip('スキップするテスト',()=>{})  test.skip('スキップするテスト',()=>{})})

また、以下のように、テストスイートはあるがテスト自体が含まれていない空のブロックがある場合、Jest では何も実行されずスルーされていました。

describe('hogehoge のテスト',()=>{// TODO テストを実装する})

Vitest ではこのようなテストスイートはエラーとなるため、こちらもdescribe.skip などに差し替える必要があります。

Error: Notest foundin suite hogehoge のテスト

E2E テストのコードは除外する

今回移行を行ったプロジェクトでは、Playwright で作成している E2E テストのコードも.spec.ts の形式で配置されています。

Vitest はデフォルトで['**/*.{test,spec}.?(c|m)[jt]s?(x)'] に合致するファイルを実行してしまうため、E2Eテスト用のコードまで実行対象となってしまいました。

そのため、明示的に E2E テストのディレクトリを除外するように設定します。

vitest.config.json
exportdefaultdefineConfig({  test:{    exclude:['client/test/e2e/**/*'],},})

しかし、このような設定にすると今度はnode_modules/ 以下にある有象無象のテストコードらしきファイルが実行対象となってしまいました。

どうやらexclude オプションは省略した場合に['**/node_modules/**', '**/dist/**', '**/cypress/**', '**/.{idea,git,cache,output,temp}/**', '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*'] がデフォルトで設定されるようです。

上記デフォルト設定のおかげで、これまではnode_modules/ 以下が実行対象から外れていたのに、exclude オプションを指定したことで自分で面倒を見る必要が出たようです。

exclude オプションにnode_modules を追記するのも良いですが、他のディレクトリも怪しいため、ここではテスト対象を明示するようinclude オプションを追加するようにしました。

vitest.config.json
exportdefaultdefineConfig({  test:{    include:['client/**/*.test.ts','client/**/*.test.tsx'],    exclude:['client/test/e2e/**/*'],}})

これによって、client ディレクトリ内のテストコードを対象とするが、E2E テストのディレクトリは除外にするというシンプルな設定にできました。

型チェックをtsc にまかせる

Jest +ts-jest を使用した場合と、Vitest を使用した場合の大きな違いとして、前者はテスト内で(デフォルトで)型チェックが行われますが、後者では行われません。

そのため、テストコードを通じて型の整合性を担保していた場合は、Vitest とは別にtsc などを用いて型チェックを行うプロセスを CI に含める必要があります。

今回のプロジェクトでは、元々 CI 内でtsc を実行していましたが、テストコード内の型チェックについてはJest にまかせるという構成だったため、tsc でテストコードも対象となるように、tsconfig.json を修正しました。

tsconfig.json
{"compilerOptions":{// 以前はテストコードは含まれないように調整されていた"include":["client/**/*"],}}

スナップショットの差分の扱いを考える

Jest にはスナップショットテスト という、テスト対象データをシリアライズしてファイルに保存し、次回のテスト実行時に差分が発生していないかを検証する機能があります。

これは、React などの UI コンポーネントの描画結果をシリアライズし、HTML/CSS レベルでの細かい差分の発生を検知してデグレを防止できる強力な機能です。

test('<TheComponent> の描画結果が変わっていないこと',()=>{const component= renderer.create(<TheComponent/>)const tree= component.toJSON()expect(tree).toMatchSnapshot()})

この機能についても、VitestJest に対する互換性を持っており、上記コードはJestVitest いずれでも動作します。

しかし、コンポーネントを描画するプロセスと、それをシリアライズするプロセスに細かな差異があるため、どうしてもスナップショットの差分が発生することは避けられません。

Difference from Jest でも触れられているように、シリアライザのオプションをJest と揃えることで、差分を抑えることができます。

vitest.config.ts
exportdefaultdefineConfig({  test:{    snapshotFormat:{      printBasicPrototype:true,},},})

多少は緩和しましたが、それでも差分をゼロにすることは困難で、その後試行錯誤を重ねた結果、今回は移行時のスナップショット差分については全て受け入れることにしました。

スナップショットテストにおいて重要なのは、差分を出さないことでなく、アプリケーションの変更を検知することです。テストフレームワークの移行で発生する差分を苦労して抑える必要はないでしょう。

ただし、React コンポーネントのスナップショットが膨大であることから、スナップショットを更新するプルリクエストは、Vitest 移行のプルリクエストと分けることにしました。


PR1: Vitest 移行を行うだけで、スナップショットの変更は含まない


PR2: スナップショットの変更のみを行う

一つ目のプルリクエストでは、スナップショットテストの差分が発生していても CI が落ちないように、以下のコマンドでテストを実行しています。

yarn vitest--update# vitest 移行直後だけ、PRをシンプルにするために一時的にスナップショット差分を無視する

この場合、差分があってもテストは成功扱いとなるので、二つ目のプルリクエストにてスナップショットを更新しつつ、CI で実行するコマンドも以下に戻します。

yarn vitest

これによって、スナップショットの差分を一瞬受け入れつつも、移行のプルリクエストをチームにレビューしてもらいやすくしました。

まとめ

本記事では、Vite を使用していないプロジェクトにおける、Jest からVitest への移行に関する意思決定理由と、その具体的な移行作業内容をまとめました。

移行手順は一見すると非常に多くの作業をしているように見えますが、実際は数時間内の作業で終わり、パフォーマンス計測や細かい設定の調整、それにプルリクエストの作成も含めても1日程度で完了しています。

これもひとえに、VitestJest に対して高い互換性を持っており、Jest からの移行を強くサポートしているおかげと思います。

比較的低コストで移行が完了したにも関わらず、得られた恩恵は大きいなと個人的にも感じております。本記事がどこかのプロジェクトの意思決定を後押しするきっかけになれば幸いです。

GitHubで編集を提案
shingo.sasaki

Webエンジニア

バッジを贈って著者を応援しよう

バッジを受け取った著者にはZennから現金やAmazonギフトカードが還元されます。


[8]ページ先頭

©2009-2025 Movatter.jp