メインコンテンツへスキップ

javascript-unit-testing-performance

· 1分で読める
非公式ベータ版翻訳

このページは PageTurner AI で翻訳されました(ベータ版)。プロジェクト公式の承認はありません。 エラーを見つけましたか? 問題を報告 →

JestはFacebook社内で常時数千ものテストを実行しています。継続的インテグレーションによるものもあれば、開発中にエンジニアが手動で起動する場合もあります。Jest開発チームのメンバーがFacebook内の他のプロジェクトに移っても、この仕組みは何年も問題なく機能してきました。

しかしながら、エンジニアがテストを追加すればするほど、Jestのパフォーマンスがスケールしないことに気づきました。さらに昨年、npm3やBabelなどの導入によりJavaScriptエコシステムが劇的に変化し、これは私たちの予想を超えるものでした。これらの課題に対処するため新たなJestチームを結成し、進捗状況と計画について本ブログで随時共有していきます。

Jestは他のテストランナーとは少し異なります。Facebookのインフラ環境で効果的に機能するよう設計されています:

  • モノレポ Facebookでは全JavaScriptコードを含む巨大なモノレポを運用しています。このアプローチが有利である理由は多数あり、Googleエンジニアによる素晴らしい講演でモノレポのメリットとデメリットが解説されています。

  • サンドボックス化 Facebookにとって重要なJestの別の機能は、テスト環境を仮想化しrequireをラップすることでコード実行をサンドボックス化し、個々のテストを分離する点です。この機能をテスト以外のユースケースでも活用できるよう、Jestのモジュール化も進めています。

  • providesModule 当社のオープンソースJavaScriptプロジェクトを見たことがあれば、モジュールにグローバルな一意識別子を割り当てる@providesModuleヘッダーを使用していることに気づいたかもしれません。カスタムツールが必要ですが、相対パスなしでモジュールを参照可能にし、開発速度の劇的な向上、組織成長に伴うスケーリング、全社的なコード共有を実現しました。実際の実装例はRelayContainerをご覧ください。ただしこのアプローチの欠点は、単一のrequire文を解決するためにJavaScriptコードベース全体を読み込んで解析する必要があることです。特にJestのような短期プロセスでは、大規模なキャッシングなしでは明らかにコストがかかりすぎます。

これらの独自の制約の結果、Jestはテストスイート全体を実行する際、他のテストランナーほど高速化できない可能性があります。しかしエンジニアが全テストスイートを実行する必要はほとんどありません。node-hasteプロジェクトの静的解析を活用し、Facebookではjest --onlyChangedjest -o)をデフォルトの実行モードとしています。このモードでは変更されたモジュールに基づいて、実行が必要な影響を受けるテストのみを逆依存グラフで特定します。

テスト実行の最適なスケジューリング

多くの場合、静的解析により複数のテスト実行が必要と判断されます。影響を受けるテスト数は数個から数千まで様々です。このプロセスを高速化するため、Jestはワーカー間でテスト実行を並列化します。Facebookの開発環境の大半がマルチコアCPUを搭載したリモートサーバーであるため、これは非常に有効です。

最近、Jestの実行終盤で_"Waiting for 3 tests"_状態が最大1分間続くことに気づきました。原因はコードベースに存在する数個の極めて遅いテストが実行時間を支配していたことです。個々のテストを大幅に高速化するとともに、Jestのテスト実行スケジューリング方法も変更しました。以前はファイルシステム走査に基づくランダムなスケジューリングでした。以下は2つのワーカーで11のテストをグレーのブロックで表した例で、ブロックサイズがテストの実行時間を示します:

perf-basic-scheduling

高速テストと低速テストがランダムに混在して実行され、最も遅いテストの1つが他のほぼ全てのテスト完了後に実行され、その間2番目のワーカーがアイドル状態になるという状況でした。

私たちはファイルサイズに基づいてテストをスケジューリングする方式に変更しました。ファイルサイズは通常、テストの実行時間の良い指標となります。数千行のコードを含むテストは、15行程度のテストよりも実行に時間がかかる可能性が高いからです。この変更によりテスト全体の実行時間が約10%短縮されましたが、さらに優れたヒューリスティックを見つけました。現在のJestでは各テストの実行時間をキャッシュに保存し、次回の実行時に最も遅いテストから優先的に実行するようスケジューリングします。この改善により全テストの実行時間が約20%向上しました。

以下は改善されたスケジューリングで同じテストを実行した例です:

perf-improved-scheduling

遅いテストを最初に実行するため、Jestの起動に時間がかかっているように見える場合があります。最初のテストが完了するまで結果を表示しないためです。将来的には、以前失敗したテストを最初に実行する計画を進めています。開発者にその情報をできるだけ早く提供することが最も重要だからです。

インラインrequireと遅延モック

Jasmineを使ったテストを書いたことがあるなら、おそらく次のような形式に慣れているでしょう:

const sum = require('sum');
describe('sum', () => {
it('works', () => {
expect(sum(5, 4)).toBe(9);
});
});

Jestで特別に行っているのは、各テスト(it呼び出し)の後にモジュールレジストリ全体をリセットすることです。これによりテストが互いに依存しないようにしています。Jest以前は、個々のテストが互いに依存し、モジュールの内部状態がテスト間で漏洩することがよくありました。エンジニアがテストを削除・並べ替え・リファクタリングすると、一部のテストが失敗し始め、原因の特定が困難になる問題がありました。

Jestでは各テストがすべてのモジュールの新規コピーを受け取ります。これは各テストごとに生成に時間のかかるモック依存関係の新しいバージョンも含みます。この設計の副作用として、各テスト前に手動でrequireを呼び出す必要がありました:

let sum;
describe('sum', () => {
beforeEach(() => {
sum = require('sum');
});
it('works', () => {
expect(sum(5, 4)).toBe(9);
});
it('works too', () => {
// This copy of sum is not the same as in the previous call to `it`.
expect(sum(2, 3)).toBe(5);
});
});

私たちはinline-requiresというBabel変換を開発しました。これはトップレベルのrequire文を削除し、コード内にインライン化します。例えばconst sum = require('sum');という行はコードから削除され、ファイル内のsumの使用箇所はすべてrequire('sum')に置き換えられます。この変換により、Jasmineと同様の自然な形でテストを記述でき、コードは次のように変換されます:

describe('sum', () => {
it('works', () => {
expect(require('sum')(5, 4)).toBe(9);
});
});

インラインrequireの大きな副次効果は、ファイル全体で使用したすべてのモジュールではなく、テスト内で実際に使用するモジュールのみをrequireする点です。

これがもう一つの最適化「遅延モック」につながります。オンデマンドでモジュールをモックするという考え方で、インラインrequireと組み合わせることで、多数のモジュールとその再帰的依存関係のモック作成を回避できます。

コードモッドを使用して、すべてのテストを瞬時に更新できました。これは_わずか_50,000行の変更でした。インラインrequireと遅延モックにより、テスト実行時間が50%改善されました。

インラインrequireのBabelプラグインはJestだけでなく通常のJavaScriptでも有用です。Bhuwanによってfacebook.comの全ユーザー向けに一週間前にリリースされ、起動時間が大幅に改善されました。

現時点では、この変換をJestで使用したい場合はBabel設定に手動で追加する必要があります。より簡単にオプトインできる方法を検討中です。

リポジトリ同期とキャッシング

オープンソース版のJestは以前、社内版のフォークでした。数ヶ月に一度のみ手動で同期しており、毎回多くのテスト修正が必要な苦痛を伴う作業でした。最近Jestをアップグレードし、全プラットフォーム(iOS、Android、Web)で同等の機能を実現した後、同期プロセスを有効化しました。現在、オープンソース版Jestへの変更は社内の全テストで実行され、どこでも一貫した単一バージョンのJestが使用されています。

フォーク解除後に最初に活用した機能はプリプロセッサキャッシュです。JestとBabelを併用している場合、JestはJavaScriptファイルを実行する前にすべてを前処理する必要があります。私たちはキャッシュレイヤーを構築し、変更のない各ファイルは一度だけ変換されれば済むようにしました。Jestのフォーク解除後、オープンソース実装を簡単に修正し、Facebookでリリースできました。これによりさらに50%のパフォーマンス向上を実現しました。キャッシュは2回目以降の実行時のみ有効なため、Jestのコールドスタート時間には影響しませんでした。

相対パスの解決時に大量のパス操作を行っていることにも気づきました。テストごとにモジュールレジストリがリセットされるため、メモ化可能な数千回の呼び出しが発生していました。大きな最適化の一つは、単一テストだけでなくテストファイル間でもキャッシングを大幅に強化したことです。以前はオートモッキング機能のためのモジュールメタデータを各テストファイルごとに生成していました。しかしモジュールがエクスポートするオブジェクトは決して変わらないため、このコードをテストファイル間で共有するようになりました。残念ながら、JavaScriptとNode.jsには共有メモリがないため、この作業は少なくともワーカープロセスごとに一度は行う必要があります。

あらゆる要素の見直し

パフォーマンス改善を試みる際には、自システムの上位・下位にあるシステムにも深く踏み込むことが重要です。Jestの場合、Node.jsやテストファイル自体などが該当します。最初に取り組んだのは、Facebook内で長年使われていたNode.js 0.10からiojs、そしてNode 4へのアップグレードでした。新しいV8バージョンはパフォーマンス向上に貢献し、アップグレードも比較的容易でした。

Node.jsのpathモジュールが数千回のパス操作時に遅くなることに気づき、この問題はNode 5.7で修正されました。Facebook内部でNode 4のサポートを終了するまで、独自のfastpathモジュールを提供します。

次に私たちは時代遅れのnode-hasteを見直し始めました。前述の通り、依存関係グラフを構築するには@providesModuleヘッダーを探すためにプロジェクト全体を解析する必要があります。このシステムが最初に構築された当時、node_modulesは存在せず、ファイルシステムクローラーも適切に除外していませんでした。

以前のバージョンでは、Jestは実際にnode_modules内のすべてのファイルを読み込んでいました。これがJestの起動時間の遅さの一因でした。Jestを再び取り上げた際、react-nativeのパッケージャーを基にプロジェクト全体を新実装に置き換えました。これにより、大規模プロジェクトでもJestの起動時間は1秒未満になりました。react-nativeチーム、特にDavidAmjadMartinはこのプロジェクトで傑出した成果を上げました。

総合的な成果

上記の変更の多くはテスト実行時間を10%、時には50%も改善しました。すべてのテストの実行時間は約10分から始まり、これらの改善がなければおそらく20分程度になっていたでしょう。しかし改善後は、すべてのテストの実行時間が1分35秒前後に安定しました!

さらに重要なのは、新しいテストを追加しても総実行時間が非常にゆっくりしか増加しないことです。エンジニアはコストを感じることなく、より多くのテストを記述・実行できます。

Jestの最新0.9リリースとnode-haste2統合によるパフォーマンス改善により、Relayフレームワークのテストスイート実行時間は60秒から約25秒に、react-nativeテストスイートは13インチMacBook Pro上で10秒未満で完了するようになりました。

これまでの成果に大変満足しており、今後もJestの改善を続けていきます。Jestへの貢献に興味があれば、GitHub、Discord、Facebookでお気軽にご連絡ください :)