とりあえずVitestを使えばテストは早くなるのか?
jsテストライブラリの内部実装調査と実行速度の改善@abema-webのおまけ部分のセルフ転載 + α です。
テストライブラリの仕組みをざっくりと説明した上でとりあえずvitestを使えば何でも早くなるのかについて考えます。
テストライブラリのざっくりとした仕組み
テストの実行の流れは次のように別れています。
- glob: テストファイルの取得
-
setup: (globの終了 ~ runの実行前)
- 依存のresolve
- テストファイルのトランスパイル
- describeやit、chain、hookの解析[1]
- プロセスやスレッドのfork(pararell runの場合)
- assertion: アサーションの実行
文字だと少しわかりにくいため非常に簡素化したテストライブラリを用意しました。手元にクローンして遊んでみてください。また、Christoph Nakazawa 氏のBuilding a JavaScript Testing Frameworkも大変参考になります。
実際にコードを見ていきましょう。シンプルなテストケースとして次のようなものをテストするとします。
// __test__/index.test.js
it("sample test", () => {
expect(true).toBe(true);
});
テストライブラリ側で用意する関数は
- it
- expect
です。
toBe
はシンプルに received
と expected
が一致していれば 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"];
この取得した testFilePath
を for
で回して評価していきます。
for (file of testFilePath) {
const code = fs.readFileSync(`${root}/${file}`, "utf-8");
eval(code);
やってることは次のコードと同じになります。it
や expect
は前に宣言していますね。
// 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されていきます。
最後にアサーションを行っていきます。 for
で tests
を回して title
と fn
を取り出します。
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のコンテクスト内でテストファイルを実行
という流れになります。
ここで、テストの流れを再度復習しておくと
- glob: テストファイルの取得
-
setup: (globの終了 ~ runの実行前)
- 依存のresolve
- テストファイルのトランスパイル
- describeやit、chain、hookの解析
- プロセスやスレッドのfork
- assertion: アサーションの実行
でした。vite
を使うことによって高速化できそうな部分は
- 依存のresolve
- テストファイルのトランスパイル
です。
vite
を使ったからと言ってjs自体の実行速度が驚異的に早くなるわけではありません。assertionやテストファイルの解析の高速化はできないため、この2つです。
また、トランスパイルに関しては jest
でもtransformerを変えれば同じことができそうです。
となると依存のresolveの高速化により実行時間の短縮は期待できそうです。
裏を返すとそこまで依存のresolveがネックになっていないテストでは jest
で回したときとあんまり変わらない!みたいなことは十分起こりうると考えています。
現状babel -> esbuild/swcでビルド時間が○○十倍になりました!みたいな衝撃はないと思っていて、個人的には jest
での最速編成に当たるものを no config で作れる + typescript
やesm
を気にしなくていい みたいな感覚でいます。
また、言うまでもないですが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周りに関しては本当にストレスフリーなのでぜひ使ってみてください。
-
ここでのhookはbeforEachやafterAllのこと。chainはit.skipやit.onlyのこと ↩︎
Discussion