- バックエンド / リーダー候補
- PdM
- Webエンジニア(シニア)
- 他19件の職種
- 開発
- ビジネス
JestでjQueryアプリのテストを綺麗に書くための考察: JestとjQueryの初期化処理を徹底解剖する
Photo by engin akyurt on Unsplash
この記事の概略
- jQueryのコードをテストするには、作成したDOM環境をどのように初期状態に戻すかが問題になる。
- この問題の最適な解決策を探るため、JestやjQueryの初期化処理がどのように行われているかを詳細に追った。
- 異なるトレードオフを持ついくつかの解決策が考えられるが、我々はアプリケーション側に手を加え、初期化処理を繰り返し実行できるようにするのが最適だと判断した。
はじめに
WantedlyのDX (Developer Experience) チームでは、自動テスト文化のさらなる普及を目指して環境整備などの活動を行っています。
その一環として、jQueryアプリケーションにもユニットテストを導入する試みをしています。そのために必要なリロード戦略について調査しました。
なぜ今jQueryなのか
Wantedlyでは2016年頃にReactを導入していますが、それ以前に作られたページではRailsで生成したビューにjQueryで振舞いをつけるのが一般的でした。それらは現在でも少しずつ手を入れながら使われ続けています。
これらの古いページの移行作業は少しずつ進んでいますが、DXのテスト増強プロジェクトではその完了を待っていられないため、jQueryのままテストの追加を進めています。
なぜユニットテストをするのか
Ruby on RailsはJavaScriptの動作をブラウザテストのみで担保する指向が強いですが、我々は高速に変化するコードベースにおいて開発者自身のために行うテストはユニットテストを中心に構成されるほうが望ましいと考えています。 (もちろんブラウザテストも必要です)
さいわい、Wantedlyの古くからあるコードベースのSprocketsからWebpackへの移行が今年に入って完了しているため、これらのコードベースはモジュールとしてNode.jsで実行可能な状態になっています。そのため、いくつかの工夫を経てユニットテスト可能な状態にする試みを行うことにしました。
JestとJSDOMについて
WantedlyではJavaScriptコードのテストに Jest を用いています。
JestにはJSDOM統合のサポートがあります。JSDOMはJavaScriptで実装されたHTML/DOM互換APIで、これを使うことでブラウザ環境をNode.js内でエミュレートできます。今回は実ブラウザを使わずに、JSDOMを使う方針にします。
また、 Testing Library はHTML/DOM上でユーザーの振舞いをエミュレートし、ユーザー視点でのUIに対するアサーションを提供します。これはしばしばReactと組み合わせて用いられますが、Testing Library自体はUIフレームワークに非依存のため、今回もTesting Libraryを使ってアサーションを書くことにします。
Jestの実行フロー
Reactアプリケーションは通常、コンポーネントとして何度もマウント・アンマウントできるように作られます。一方jQueryアプリケーションでは1回限りの初期化処理でDOMにイベントハンドラを登録して終わり、というものが多く、ユニットテストのための繰り返し実行には工夫が必要になります。
この問題を解決する最適な方法を考えるために、まずはJestの実行フローを確認します。
ファイル並列性
Jestでは複数のテストファイルに複数のテストケースを書けるようになっています。1つ1つのテストファイルは次のような書き心地です。
import { f } from "./my-program";
test("it returns the correct answer", () => {
expect(f(21)).toBe(42);
});
test("it raises an exception on an invalid input", () => {
expect(() => f(null)).toThrow();
});
Jestのデフォルト設定では、 **/*.test.js
や **/__tests__/*.js
などのファイル名を持つファイルがテストファイルとみなされます。
Jestは特定の条件を満たしたとき (→ shouldRunInBand, serial) にテストの並列実行を行います。これはおおよそ以下のような条件です。
- ワーカー数指定 (maxWorkers) が2以上である。
- かつ、実行対象のテストファイルが2ファイル以上ある。
Jest 29時点では、並列実行は自Node.jsプロセスのforkを作ることで実現されています。同一Node.js内にワーカースレッドを立ち上げる worker_threadsベースの実装 も準備されていますが、まだ有効化されていないようです。
並列化しない場合は、Jestを起動したときと同じNode.jsスレッド内でそのまま起動されます。
並行性
並列性とは別に、非同期テストケースを並行実行する機能があります。これはjest-circusとjest-jasmine2でそれぞれ独立に実装されており、各テストで明示しない限りは無効です。
つまり、特に明示しない限り、1つのテストファイル内のテストは、1度にひとつだけ実行されます。
サンドボックスの作成
以降では、通常のテストランナー (jest-runner) を使った場合の挙動を説明します。特殊なテスト (TypeScriptの型定義のテストや、Playwrightによるテストなど) を行うときは、別のテストランナーを用います。
実行すべきテストファイルが決まったら、テストを実行するためのサンドボックスを作ります。これには以下のコンポーネントが関係しています。
- 環境
- ランタイム
- テストフレームワーク
Jestはまず環境を作ります。デフォルトではjest-environment-nodeが使われますが、JSDOMを使う場合はjest-environment-jsdomを使います。
ここでいう環境とは、基本的にはグローバルオブジェクトと関連する定義一式のことで、ECMAScript用語で言うとおおよそRealmに相当します。異なるRealmは異なるグローバルオブジェクトのもとで動きますが、オブジェクトを共有することができます。Node.jsでは vmモジュール を使ってこれらを操作します。
- jest-environment-node の場合は、 vm.createContext() で作った最低限の環境に対し、ホスト側のNode.jsグローバル変数をコピーして環境を作る。
- jest-environment-jsdom の場合は、JSDOMで作った仮想ウインドウオブジェクトをコンテキスト化し、いくつかどうしても必要なNode.jsグローバルを足して環境を作る。
作った環境はランタイムにアタッチされます。ランタイムの最も重要な仕事はNode.jsと同等のモジュールシステム (require/import) を提供することです。
ランタイムができたら、まずsetupFilesで指定されたファイルが読み込まれます。これはテスト環境の中で実行されます。
こうして作られた環境とランタイムはテストフレームワークに渡されます。テストフレームワークとはtest, it, describe, expectなどテストコードの語彙を提供する部分で、デフォルトではJest謹製の jest-circus/runtime が使われます。以前は、Jasmine 2.0 からフォークされたコードからなる jest-jasmine2 がデフォルトでした。
テストフレームワークは環境に必要な語彙 (testやitなど) のための設定を追加したあと、指定されたテストファイルを実行させます。
テストコードの読み込み
以降はjest-circusやjest-jasmine2など、通常のテストフレームワークを使った場合の話をします。
テストフレームワークはまず必要な語彙を環境に登録します。これによりtest, it, describeなどの関数が使える状態になります。
この状態で、setupFilesAfterEnvで指定されたファイルが読み込まれます。 (テスト環境の中で実行されます)
そして、ランタイムのAPIを用いて、指定したテストファイルをテスト環境の中で実行します。この時点では、テストケースは実行されません。
たとえば、以下の例では1, 2, 3の3つの行だけが実行されます。
/* 1 */ import { f } from "./my-program";
/* */
/* 2 */ test("it returns the correct answer", () => {
/* x */ expect(f(21)).toBe(42);
/* */ });
/* */
/* 3 */ test("it raises an exception on an invalid input", () => {
/* x */ expect(() => f(null)).toThrow();
/* */ });
この時点で、テストケースの一覧が得られた状態になっています。
テストケース実行
得られたテストケースを順次実行します。
Jestでは describe によりテストケースのグループを作ることができます。テストファイル全体も暗黙のdescribeブロックであるとみなして、そのdescribeブロックの実行を行います。describeブロックの実行は以下のように行われます。
- このブロックに紐づいたbeforeAllを順次実行する。
- 次に、中のdescribeブロックまたはテストケースを順次実行する。
- 最後に、このブロックに紐づいたafterAllを順次実行する。
テストケースは以下のように実行されます。
- このテストケースに対して有効なbeforeEachを順次実行する。
- 次に、テストケース本体を実行する。
- 最後に、このテストケースに対して有効なafterEachを順次実行する。
また、並行実行可能なテストはファイルの冒頭でまとめてディスパッチされます。
Jestの実行フローまとめ
- テストファイルごとにテスト環境がセットアップされ、実行される。
- setupFilesやテストファイル自体を含め、ユーザーが書いたコードは基本的にテスト環境下で実行される。
- 同じテストファイル内ではテスト環境は使い回される。
- 同じテストファイル内では原則としてテストケースは逐次実行され、同時に実行されることはない。
@testing-library/react の環境分離
ここまでで説明したように、Jestではテストファイル内ではテストケースごとに環境はリセットされません。JSDOMを使ったテストでは環境下のグローバル状態であるDOMを積極的に変更していくため、テストケース間での副作用の分離が重要になります。
たとえば @testing-library/react の場合、 render という関数を使うとdocument.bodyにルート用のdiv要素が追加され、そこにReactコンポーネントがマウントされます。 cleanup という関数を使うことで、マウントしたコンポーネントを片付けることができます。このcleanupは自動的にafterEachフックとして登録されるため、基本的にはテスト終了時に環境は元通りになっていることになります。
これでうまくいくのは、ReactというフレームワークやReactで書かれるアプリケーションが極力グローバル状態を持たないように作られるからです。 (かわりにReact Contextなどで擬似的にグローバルな状態管理を再現することはある)
jQueryアプリケーションでは、初期化時に様々な副作用を分散的に適用することが多く、繰り返しテストできる環境の構築には工夫が必要になります。
jQueryのイベントアタッチメント
Jest + JSDOM環境では同一のドキュメントを清掃して使い回す必要があるため、イベントの発火条件を詳しく見ていく必要があります。jQueryでイベントをアタッチする典型的なコードがどのように動作するかを確認します。
直接的イベントハンドラと移譲イベントハンドラ
jQueryでは通常 .on またはその短縮形 (.click など) を使ってイベントハンドラを登録します。これは通常以下のような形をしています。
$(".my-button").on("click", function(e) { ... });
これは指定したセレクタでquerySelectorAllを行い、各要素にaddEventListenerして回るのと同等です。
(ただし、jQueryはDOMのイベントシステムをそのまま使うのではなくラップして使っています。たとえば、ひとつの要素にはイベント種別ごとに最大ひとつのイベントリスナしか登録せず、その中でのイベントの多重化はjQuery内で行っています。)
この方法の場合、イベントハンドラを登録した後にセレクタに合致する要素が追加されても対応できないという問題があります。
一方jQueryには移譲イベントハンドラという機能があり、以下のように書くことができます。
$("#root").on("click", ".my-button", function(e) { ... });
この場合は第1のセレクタでquerySelectorAllを行い、各要素にaddEventListenerして回ります。ただし、第2のセレクタに合致した要素からバブリングしたイベントだけを拾うという条件がつきます。
この方法の場合は、第1のセレクタに合致する要素さえあれば、実際に反応させたい要素が動的に追加されても対応できます。しかし、そもそもの第1のセレクタに合致する要素自体が無くなってしまった場合はどうしようもありません。
readyハンドラ
jQueryではreadyハンドラという特別な関数を登録することができます。
$(() => {
console.log("document is ready");
});
readyハンドラは $.ready のラッパーであり、おおよそ以下と同等です:
await $.ready;
console.log("document is ready");
$.ready は $.Deferred (jQueryがもつPromises/A+互換実装) のインスタンス (のthenメソッドだけをコピーしてきたオブジェクト) で、以下のいずれかの条件でfulfillされます。
- jQueryが読み込まれた時点でドキュメントが読み込み済み (document.readyState が "loading" 以外になっている) のとき、0ミリ秒待ってからfulfillされる。
- documentのDOMContentLoadedイベントが発火したらfulflilされる。
- windowのloadイベントが発火したらfulfillされる。
$.ready.then はPromises/A+互換のため、fulfillされてすぐには発火しません。実験的に確認した限りでは1マイクロタスクではなく1タスク分の時間がかかるようです。
$.ready.then はPromises/A+互換のため、登録したイベントハンドラが1度しか発火しないことは保証されています。
$.ready のDeferredインスタンスはjQueryのロード時に内部的に一度だけ作られ、それ自体は露出されていません。そのため、同じドキュメント内でreadyハンドラを2回以上発火させるのは難しそうです。
JSDOMのロード状態
document.readyState はシンプルなアクセサプロパティとして実装されており、初期値はデフォルトで loading です。
readyStateを変更する主な方法は document.close の呼び出しです。メインのDocumentインスタンスはJSDOMの初期化処理の最後に閉じられるため、ユーザーから見えるようになった時点ですでにinteractiveまたはcompleteになっています。 (DOMContentLoadedもこの段階で読まれると考えられます)
そのため、JestのJSDOM環境でテストする場合は、読み込み済みのドキュメントに対して追加でJavaScriptの処理を流し込むのと同等とみなせます。
リロード戦略を考える
ここまでで前提を整理することができたので、リロード戦略の候補を挙げていきます。解決したいのは以下の問題です。
- テストケース間の干渉を防ぐために、前のテストが残したDOMがそのままになることはなるべく防ぎたい。
- その上で、イベントハンドラの登録が毎回意図通りに行われるようにしたい。
(A) 1つのテストファイルに1つのテストのみを書く
派生として、「各テストファイルでは全ての操作をbeforeAllで行い、各テストケースにはアサーションだけを書く」というものも考えられます。
この方法なら、各テストファイルではテスト環境が完全に分離されているため、テストケース間の干渉はほぼありえません。
代償として、テストケースの構造化に大きな制約を受けることになります。
(B) 常にルート要素を起点にイベントの移譲を行い、テスト後にはルート要素以外をクリーンアップする
ルート要素として #root のような専用のdiv要素を置く方法のほかに、 document.body を使ってしまうという方法もあります。
この方法の最大の問題点は、アプリケーション側の挙動を変える必要があることです。イベントハンドラの移譲を行うのと行わないのでは挙動に差異がある上、トレードオフも存在します。そのため、この方法は単純なリファクタリング以上の作業や判断をしないと行うことができません。
(C) テスト後にDOMのクリーンアップとモジュール状態のリセットを行う
Jestのランタイムには jest.resetModules という、モジュールキャッシュのリセットを行う機能があります。リセットすると、次に同名のモジュールを呼び出したときはモジュールの初期化コードが再実行されます。
この方法は一見全てをリセットできて正しく動作するように思えますが、アプリケーションの実装次第では完全にクリーンアップされない場合があります。たとえば、
- グローバルオブジェクトのどこかに状態が保存されてしまっている。
- setTimeoutなどのコールバックとして永続的に動作するループが残ってしまっている。
- windowなどに対するグローバルなイベントハンドラが設置されたままになってしまっている。
などが考えられます。
resetModulesのかわりに jest.isolateModules を使うことでリロード範囲を限定させることができますが、今度は非同期処理との相性問題が発生します。 (→ #10428)
(D) jQueryの実行済みreadyハンドラをもう一度実行する仕組みを作る
これは前述のように、 $.ready がDeferredになっていて実行回数が綿密に管理されているため、無理矢理2回以上実行するのは難しいです。
(E) アプリケーションに手を入れ、イベントハンドラのアタッチ処理を再実行できるようにする
この方法は特別な仕組みは特に使用しないため、比較的安全で簡単です。
次のように、jQueryのready処理をラップした関数を提供して、それを使うようにします。
$(() => {
$(".my-button").click(function(e) { ... });
});
// ↓
registerInit(() => {
$(".my-button").click(function(e) { ... });
});
内部では渡された初期化処理を実行しながら、コールバックを覚えておきます。テスト中は、覚えておいたコールバックを再生することで、イベントハンドラの登録状態を再現します。
この方法の最も大きな問題点は、アプリケーション側に「テストのための処理」が混入してしまうことです。一般的に、このような書き方をすると本番環境のアプリケーションとテスト環境のアプリケーションの乖離が大きくなるリスクがあります。
また、コールバックを記憶するため、理屈の上ではメモリリークが発生する余地があります。ただ、初期化処理のためのクロージャが束縛するデータはふつう何もしなくてもメモリ上に永続化されるデータがほとんどだと考えられ、これが実際に問題になるケースはおそらく稀でしょう。
Wantedlyではどうするか
先ほど説明したいくつかの解決策のうち、 (A) はテストの構成に対する制約が厳しすぎるほか、 (B), (C) はいずれもWantedlyでのアプリケーションの現状にそぐわないものでした。
そのため、アプリケーション側にテスト用のコードが混入するデメリットを受容しつつ、 (E) の方法を試してみようと考えています。