とりあえずVitestを使えばテストは早くなるのか?


jsテストライブラリの内部実装調査と実行速度の改善@abema-webのおまけ部分のセルフ転載 + α です。

テストライブラリの仕組みをざっくりと説明した上でとりあえずvitestを使えば何でも早くなるのかについて考えます。

テストライブラリのざっくりとした仕組み

テストの実行の流れは次のように別れています。

  1. glob: テストファイルの取得
  2. setup: (globの終了 ~ runの実行前)
    • 依存のresolve
    • テストファイルのトランスパイル
    • describeやit、chain、hookの解析[1]
    • プロセスやスレッドのfork(pararell runの場合)
  3. assertion: アサーションの実行

文字だと少しわかりにくいため非常に簡素化したテストライブラリを用意しました。手元にクローンして遊んでみてください。また、Christoph Nakazawa 氏のBuilding a JavaScript Testing Frameworkも大変参考になります。

実際にコードを見ていきましょう。シンプルなテストケースとして次のようなものをテストするとします。

// __test__/index.test.js
it("sample test", () => {
  expect(true).toBe(true);
});

テストライブラリ側で用意する関数は

  • it
  • expect

です。
toBe はシンプルに receivedexpected が一致していれば true を返し、そうでなければ Error をthrowします。
また、it は第一引数にタイトルを第二引数に関数を受け取る関数です。それらを適当な配列へpushします。

// index.js
const tests = []

//matcherの定義
const expect = (received) => ({
  toBe: (expected) => {
    if (received !== expected) {
      throw new Error(`Expected ${expected} but received ${received}.`);
    }
    return true;
  },
});

// test関数の定義(testsは適当な配列)
const it = (title, fn) => tests.push([title, fn]);

次にファイルの取得です。本来ならば適当な glob ができるライブラリを用いてテストファイルのパスの配列を取得します。

// 1. glob(本来であればglobを使う)
const testFilePath = ["__test__/index.test.js"];

この取得した testFilePathfor で回して評価していきます。

for (file of testFilePath) {
  const code = fs.readFileSync(`${root}/${file}`, "utf-8");
  eval(code);

やってることは次のコードと同じになります。itexpect は前に宣言していますね。

// index.js
for (file of testFilePath) {
  const code = fs.readFileSync(`${root}/${file}`, "utf-8");

  // ↓ evalの部分。引数の `code` の内容がそのまま実行される。
  it("sample test", () => {
    expect(true).toBe(true);
  });

.....

すると関数 it が実行され、配列 testsにpushされていきます。

最後にアサーションを行っていきます。 fortests を回して titlefnを取り出します。
fn は先程のテストファイルのアサーション expect(true).toBe(true) に当たります。

これを try 内で実行します。もしも関数 expect でエラーが発生すれば catch 節へ行きます。

try {
      // 3. assertion実行(今回の場合`expect(true).toBe(true)`)
      fn();
      result.success = true;
    } catch (e) {
      // もしもfn()でエラーが起こったらcatch節へ
      result.error = e;
    }

これが大まかなテストライブラリの実行の流れになります。
テストライブラリの仕組みを知ることで

  • 大部分は js のコード自体を評価して実行する
    • この部分を早くするというのは Node.js の実行スピードを上げることとほぼ同義なのであまり現実的ではない
    • 高速化には並列実行を効率的に行うしかない
  • よくあるswcやesbuildを使ったとしても setup フェーズのトランスパイルの部分しか高速化できない。

ということがわかったと思います。 ライブラリの外からパフォーマンス改善のために手を加えられる部分は少ないのです。

Vitestでを使えば何でもかんでも早くできるか

ケースバイケースだと個人的に考えています。 まずは理屈っぽい話から。
vitest のアイデアは単純明快でリゾルバーとして vite を使おうというものです。
核はvite-nodeというパッケージです。これはREADMEの通り Vite as Node runtime であり、Browserの代わりにRunnerがclientに当たります。

vitest におけるjs/tsファイルの実行は

  • Viteで諸々transform -> vmのコンテクスト内でテストファイルを実行

という流れになります。

ここで、テストの流れを再度復習しておくと

  1. glob: テストファイルの取得
  2. setup: (globの終了 ~ runの実行前)
    • 依存のresolve
    • テストファイルのトランスパイル
    • describeやit、chain、hookの解析
    • プロセスやスレッドのfork
  3. assertion: アサーションの実行

でした。vite を使うことによって高速化できそうな部分は

  • 依存のresolve
  • テストファイルのトランスパイル

です。
vite を使ったからと言ってjs自体の実行速度が驚異的に早くなるわけではありません。assertionやテストファイルの解析の高速化はできないため、この2つです。

また、トランスパイルに関しては jest でもtransformerを変えれば同じことができそうです。
となると依存のresolveの高速化により実行時間の短縮は期待できそうです。

裏を返すとそこまで依存のresolveがネックになっていないテストでは jest で回したときとあんまり変わらない!みたいなことは十分起こりうると考えています。

現状babel -> esbuild/swcでビルド時間が○○十倍になりました!みたいな衝撃はないと思っていて、個人的には jest での最速編成に当たるものを no config で作れる + typescriptesm を気にしなくていい みたいな感覚でいます。
また、言うまでもないですがwatchでの差分実行は他と比較にならないほど爆速です。

vitestがパフォーマンスに関して現状ぶち当たってる課題

話が全然変わりますが現在進行系で確認されている問題についても触れておきます。

workerの挙動

あまり安定していないケースがあるようです。(もちろんあまり影響していないケースもある)
vitestデフォルトでisolateが true になっています。このisolateは単にparallelに実行するときの worker を都度生成し直すことでenv pollutionを防ぎます。(実装はvitestではなくtinypoolというライブラリ)

しかし、これが原因でやたら実行が遅くなるケースが確認されており、--isolateを切ると早くなります。(jest vs jasmine)おそらくworkerの生成や消去の乱発をし過ぎなんじゃないかなーみたいな話が出ています。
回避のためには実行の際に

--isolate=false

をつけましょう。

別の話でprofilerで解析したところ一部workerが無意味にアイドル状態になっている期間があり、あまり効率よく実行できていないケースがあるようです。

v0.6.0 ?

これは原因がわかっていないのですが、v0.6.0 を境に謎に若干遅くなっている現象が起きています。ちょくちょく vitest を試す記事が出ていますが、バージョン 0.6.0 以前のものであれば、最新版で再度実行すると結果が変わるかもしれません。

最後に

まだメジャーバージョンすら出ていないですが、もしかしたらvitestを使っても早くならないケースもあるかもねという話でした。
自分自身まだあまり内部実装(特にvite)に関しては詳しくないのでwatchしていこうと思っています。

また、vitestはesmやTypeScript周りに関しては本当にストレスフリーなのでぜひ使ってみてください。

脚注
  1. ここでのhookはbeforEachやafterAllのこと。chainはit.skipやit.onlyのこと ↩︎