togami2864

Tuesday, Apr. 1 2022

jsテストライブラリの内部実装調査と実行速度の改善@ABEMA

追記(2022/04/02): いくつか不十分な点を訂正。

2022年2月にABEMA Webチームでjsテストライブラリの内部実装調査とライブラリの移行検証、実行速度の改善を行いました。メンターの@nodagutiさんを始めabema-webチームの皆様ありがとうございました。

早速本題に入りますが、タイトルの通り次のようなことを行いました。

  • jsテストライブラリの内部実装調査
  • ABEMA Webでのjest移行検証
  • ユニットテストの実行速度改善

最終的にはローカルにおける実行速度を 約80% 高速化しました。

モチベーション

1: 実行に時間がかかる

ABEMA Webではテストライブラリとして ava を、テスト実行時のトランスパイラに babel と各種 reacttypescript のプラグイン(preset-reactpreset-typescript など)を使用しており、clientに関するテストケースが 約3186件 存在しています。そしてローカルですべてのユニットテストを実行すると 約10分 かかり、実行するのが億劫な状態でした。

2: エコシステム面で不安

esbuildやswcといったコンパイラを用いた jest の実行時間の高速化や vitestの登場など新たなコンパイラを用いたテスト環境が取り上げられるケースが多くなりました。 これらのエコシステムの充実度合いや将来性を考えると不安が残るため、移行を検証してみてみました。

ライブラリの内部調査

実行速度のボトルネックとなっている部分を知るために ava のコードを実際にコードリーディングを行いました。その結果、テストの実行の流れを勝手に3つのフェーズに分けました。

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

文字だと少しわかりにくいため非常に簡素化したテストライブラリを用意しました。手元にクローンして遊んでみてください。

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

// __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 の実行スピードを上げることとほぼ同義なのであまり現実的ではない
    • 高速化には並列実行を効率的に行うしかない
  • トランスフォーマーは setup フェーズのトランスパイルの部分しか高速化できない。

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

そのため身もふたもないのですが、一つ一つの実行スピードを縮めるのはあまり現実的で無いため、如何にコンピュータに効率よくタスクをさばかせられるか(結局コア数指定増やしてparallel run)が改善の鍵だと考えました。

これを踏まえて、高速化のための施策として

  • トランスフォーマーを変える
  • 論理コア数を使える最大まで使ってparallelにテスト実行

を行うことでABEMA Webのテストがどこまで早くできるか、移行とセットで検証をしました。

また、参考程度ではありますが、一つテストタスクあたりの各フェーズにかかる時間を測定して大小比較を試みました。もちろんこれはライブラリ間で単純比較できるものではありませんし、測定の都合上正確な数字ではありません。

chart-phase

当たり前ではありますが処理盛りだくさんの setupassertion の約6倍程度かかっていました。 また glob の全体に対する割合は小さいことがわかります。

jestへの移行検証

調査を踏まえて、abema-webのテスト環境を jest へ移行をはじめました。1 大まかな流れとして

  1. jest-codemodsによる一括変換
  2. 残りを変換
    • jest-codemodsが非対応なところ
    • avaのコンテクストに依存して独自に実装していたところ

jest-codemodsは avajasmine から jest に移行するためのマイグレーションツールです。このツールで最初に一括でコードを変換しました。

次に残りの部分ですが、jest-codemods が未対応なものとして次のものが挙げられます。

  • t.like
  • t.pass / t.fail
  • t.context など

これらの部分と独自に作っていた assertProviderMock をVSCodeの置換やjs-codeshiftのtransformerを自作して変換を行っていきました。

トランスパイラについて

再掲ですが ABEMA Webのテスト環境では babel と各種 reacttypescript のプラグイン(preset-reactpreset-typescript など)を使用しています。

preset-typescript は型チェックは行っていないので変更しても問題ないと判断しました。今回は次の2つのツールを検討しました。

esbuild-jest

ABEMA Webでは開発用ビルドで esbuild を使っているため統一できると考え、最初に検討しました。しかし、

  • jestの最新バージョン v27 に対応していない
  • メンテナンスがあまりされていない

ことから今回は試しませんでした。

@swc/jest

swc-project公式でメンテナンスされており、jestのv27でも問題なかったため今回はこちらを採用しました。また、思わぬ副産物として今まで使っていたプラグインがなくてもすべて正常に動作したため、configが非常に簡潔になりました。

速度の計測

ローカル環境では

  • AVA + Babel(コア3)
  • AVA + Babel(コア数15)
  • jest + Babel(コア数15)
  • jest + swc(コア数15) を比較しました。

MacBook Pro 2019 上であり、論理コア数は16でした。2 そのため jest の実行コマンドオプションに --maxWorkers=15を指定して実行しました。

CircleCi上ではDockerExecutermedium でコア数は2です。同様に jest の実行コマンドオプションに --maxWorkers=1を指定した上で、

  • AVA + Babel
  • jest + Babel
  • jest + swc

を比較しました。

テストはABEMA Webのclientの関する部分で、すべて実行は5回ずつ行ってその平均を取りました。

速度の比較(ローカル)

AVA+Babel(3)AVA+Babel(15)jest+Babeljest+swc
110m 13s5m 54s2m 6s1m 52s
210m 14s6m 3s2m 4s1m 53s
310m 12s5m 51s2m 5s1m 52s
410m 13s5m 50s2m 4s1m 51s
510m 13s5m 49s2m 5s1m 51 s
平均10m 13s5m 53s2m 5s1m 52s

chart-local

  • ava + babel(コア3) -> jest + swc 構成への移行で 約80% の短縮
  • ava + babel(コア15) -> jest + swc 構成への移行で 約70% の短縮

をすることができました。 概ね仮説通り実行コア数を増やすことは効果的であり、大幅な実行時間の改善を行うことができました。また、トランスパイラによる差異もせいぜい10秒未満であり影響は小さかったと言えました。

速度の比較(CircleCI)

AVA+Babeljest+Babeljest+swc
13m 36s3m 38s3m 10s
23m 27s3m 33s3m 11s
33m 20s3m 20s3m 6s
43m 13s3m 11s2m 47s
53m 44s3m 14s2m 53s
平均3m 28s3m 23s3m 1s

chart-ci

こちらはあまり劇的な改善をすることができませんでした。ローカルではコアを富豪的に使えたこともあってそれに比べると、リソースが厳しく、あまり全体的に大きな差異が出ませんでした。

一方で仮説とは反対にトランスパイラによる違いがはっきりと現れており、30秒 ~ ほど早くなりました。 今回測定した中では2分台が出たのは swc を使ったパターンのみでした。

また、別の問題として、今回は変更を加えなかったCircleCIのparallelismの挙動に偏りがあり、それを解消できなかったことが挙げられます。コンテナ1は2分40秒ほどで終わっているのにコンテナ2は3分40秒かかるみたいな現象が起きていました。

残った課題

先程言及したとおり、CircleCIのparallelismの挙動となぜCircleCI環境下では顕著に早かったかまでは深く探求することができませんでした。3また、今回の測定ではCPU負荷やテスト環境のisolationに関して言及できていなかったため、まだまだ調査が必要だと感じています。

最後に

まだまだ粗のある調査でしたが、とてもおもしろかったです。改めて、abema-webチームの皆様、ありがとうございました!!

おまけ: vitestを使っておけば早くなるのか

watchモードではなく通常実行(CIとかで単発で実行するとき)での話です。

  • バージョン
    • v0.8.0

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

vitest

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

というのを繰り返して動作していることになります。

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

  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 で回したときとあんまり変わらない!みたいなことは十分起こりうると考えています。(現にちょこちょこissueやdiscordで見かける)

現状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の生成や消去の乱発をし過ぎなんじゃないかなーみたいな話が出ています。

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

  • v0.6.0 ?

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

Footnotes

  1. 他に候補もあったのですが時間の都合で試せませんでした。

  2. PCをとっくの昔に返却してしまいこれ以上わからない。。

  3. swc云々というよりもローカルの方では parallel runのオーバヘッド > swcが短縮した時間になってしまいこのような結果になった可能性も考えられる。

Back to Home