今年のAngularアドカレはテストの話題が豊富ですね。 Angular8 ユニットテストが動かねぇ! や はじめてのユニットテストを書く前にモック実装で手が止まってしまわないために など、テストコードを書く上での知見が色々と紹介されています。
僕自身も3年前のアドカレで、Zone.jsがいかにAngularのテストに貢献しているかとかを書いたりしたことがあり、Angularの色々な機能の中でもテスト周りは特に好きな部分の1つだったります。
さて、Angularで直近ホットだった話題といえば、やはりIvyは外せません。
Ivy自体は基本的に内部構造の変更であるため、アプリケーションコードの書き方そのものに影響する話ではないですが、AoT(Ahead of Time)コンパイルをデフォルトにするための布石であり、バンドルサイズの削減やビルドプロセスがシンプルになる、といった効果については聞いたことがある方も多いと思います。
今日取り上げたいのは、**Ivyで単体テストが爆速になるよ!**というテーマです。
実はAngular公式のblogでもIvyによって単体テストのパフォーマンス向上が見込める件については言及されています( It’s time for the compatibility opt-in preview of Ivy!より)。
Better testing performance — Within Angular, we’ve seen framework unit tests are 1.5x faster, Material Unit tests are 2.7x faster, and memory usage in Material unit tests is down 81%
Angularの単体テストはとてもとても遅い
Ivyによる効果を説明する前に、Ivy以前の課題について触れておきます。
ある程度の規模でAngularのプロジェクトをやったことがあると肌感として知っているかもしれませんが、そもそもAngularの単体テストは遅いです1。「webpackでbuildしてKarmaでChrome立ち上げてテスト回すのが遅いのは仕方無いのでは?」と指摘されるかもですが、そういう話じゃないんです。
どういうことなのか、ちょっとサンプルで見てみましょう。
上のキャプチャは、適当にng newしたAngular 8.xのプロジェクトで100コンポーネント、100specsを書いてKarmaを回した際のJavaScript CPU profileです。
全体の実行時間に8秒程度要している中で、compileModuleAndAllComponentsAsync
という関数が6秒近くを占めているのがわかります。およそ75%です。名前から察しが付くと思いますが、TestBedで必要となるModuleやComponentをコンパイルするための関数ですね。Componentのコンパイルなので「.htmlや.cssのパースして、viewDef相当を作って、、、」というのがおよその中身ですが、まぁ遅そうな中身ですよね。
今回検証で用いたのは下記のコードですが、TestBed.configureTestingModule(...).compileComponents
がこの関数の呼び出し元に相当します。
import { TestBed, async } from '@angular/core/testing';
import { AppComponent } from './app.component';
import { AppModule } from './app.module';
describe('AppComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
AppModule,
],
}).compileComponents(); // これ
}));
[...new Array(100).keys()].forEach(i => {
it('should create the app' + i, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance;
expect(app).toBeTruthy();
});
});
});
特に上記のコードのようにTestBedにModuleをまるっとimportさせると、そのModuleに含まれる全てのComponentのコンパイルが毎specで動作します。これが何を意味するかというと、テストの実行時間がComponentの数に対して非線形 $O(n^2)$ に増大するということです。
今回の例は ng g c
しただけのComponent x 100個 + Jasmineのspec x 100個を作っただけですが、実際のアプリケーションではComponentのHTMLやCSSはより複雑ですし、1 Componentあたりのspec数ももっと多いでしょう(ちゃんとテスト書いてればね)。
ちょっとした変更をpushしただけで、CI上でのKarmaに何分間も待たされるのは御免被りたいわけです。
Ivyの効果
さて、現状の問題も説明したところで、実際にIvy適用によってどれくらい改善されるかを測ってみましょう。
元々8秒近く要していたところから、2秒以下まで削減されましたね。4倍以上高速されています。浮いたのが約6秒というのからも推測ができる通り、削減されたのはまさにボトルネックとなっていた compileModuleAndAllComponentsAsync
の部分です。
先述した通り、従来のAngularの単体テストでは「Componentが増えれば増えるほど、そのコンパイル時間でテスト実行時間が占有されていく」ということを考えると、コンパイル時間の部分がごっそり削減されるのはとても嬉しいですね。
Ivyさまさまです。後述しますが、今まではテストの実行時間を削るにはそれなりのhackが必要でしたが、Ivyに関して言えば、ただただ有効化するだけですし、何ならAngular 9.xからはIvyはデフォルトで有効化されるので、この悩みからようやく解放されることになったわけです。
なぜ高速化されたのか?
ここからは少しマニアックな内容に突っ込んでみようかと思います。
今回のエントリで一番言いたかった「Ivyすごい!テスト速くなった!嬉しい!」はもう書いたのですが、これで締めくくると若干アホの子っぽいので、折角なので高速化の理由をもう少しちゃんと追ってみました。
Q. AoT関係あるの? A. 実はそれは関係ないよ
「Ivyが単体テストのパフォーマンス向上に寄与する」と聞いたときに、僕が最初に思い浮かんだのは、IvyとAoT by defaultの関係でした。
実際、Ivyを手動で有効化する場合は下記の手順となりますが、ここに「ビルドでAoTを有効化しておく」というのが出てきますし。
- tsconfig.jsonに
enableIvy: true
を記述 - angular.jsonの
build
architectにて、aot: true
を記述
before Ivyなテストのボトルネックである compileModuleAndAllComponentsAsync
はJiTコンパイルを行う関数ですから、「Karmaのテスト時にもAoTを有効化させてしまえば、実行時コンパイルは行われなくなって速くなるよね」と思ったわけです。
が、結論でいうと、これは誤りでした。
よーくよく考えると、 aot: true
というAOT設定の有効化は、あくまで build
というarchitectに与えたオプションです。
念の為に補足しておくと、architectとは、Angular CLIの serve
や test
といったngに食わせるビルド系サブコマンドの実体であり、いわゆる「タスク」の固まりを意味します(ちなみに雛形生成系のサブコマンド実体がschematics)。
ng test
については、build
とは別の karma
というarchitectが動作します。angular.jsonでいうと、test
というキーの配下で設定しているヤツですね。 aot: true
はKarma architectの設定には一切影響を及ぼさないため、Ivyを有効化したところで、(Angular CLI 9.xも含めて)現状ではKarmaではJiTコンパイルが動作し続けます。
Q. enableIvy: true
だけでなんで高速化されんの? A. JiTの実装がまるで違うから
ということで、今回の高速化の切り替えは、純粋にtsconfig.jsonに記述した enableIvy: true
の方だけです。
これは言い換えると、View EngineをRender 2からIvy(Render 3)へ切り替えろ、という意味になります。
この enableIvy
というオプション、Angular CoreのView Engineだけでなく、実はTestBed実体と、TestBedに紐づくTesting Compilerの実装も差し替わるようになっていて、それぞれ TestBedRender3
、 R3TestBedCompiler
というIvy専用の実装に置き換わります。 TestBed.configureTestingModule
という記法なのに、実装がいつの間にかすげ替えられてるんですね。個人的には「static メソッドとは。。。」という気持ちにさせられますが、 enableIvy
がtsconfig.jsonに書く以上、tsコンパイラであるngcの時点で何でもできてしまうので、static メソッドだからといって常に同じ実装とは限らない、という話です。
閑話休題。さて、Ivy専用のTesting Compilerである R3TestBedCompiler
は、実際のComponentコンパイル処理をAngular CoreのIvy用JiTコンパイラに委譲します。
- Render 2の場合:
TestBed.configureTestingModule(...).compileComponents()
の時点でJiTコンパイルが動作し、specで必要となるModuleやComponentのコンパイルが実行される - Ivy(Render 3)の場合:
TestBed.configureTestingModule(...).compileComponents()
はAngular Core Ivy JiTコンパイラへ、Moduleをキューイングするだけ。その後、キューに入れらたModule定義にしたがって、JiTコンパイラがModuleやComponentのコンパイルを実行する
Render 2でもIvyでも、処理の流れ自体はほとんど一緒なんですが、Ivyの方は「Module定義情報のキューイング」というステップを踏んでいるところがポイントです。Ivy用のJiTコンパイラは、キューから取り出してコンパイルを実行する際に「コンパイル済みのModuleはスキップする」という実装がされています2。
というわけで、「specの数がいくつであろうと、一度importsに列挙したModuleであればJiTコンパイルが1回しか動作しないから速い」というのが真相でした。cacheとかflyweightパターンの話ですね。
cacheとTesting Compiler
ブラウザでアプリケーションを動作させる場合と異なり、Testing Compilerで面倒なのは、 overrideModule
や overrideComponent
など、テスト用に特定の依存関係をstubしたり上書く機能を提供する必要があり、この手の機能を使うと「同名のModuleであっても実体がspec毎に異なる」という状況を作り出すため、今回のようなcache系の高速化とはめちゃくちゃ相性が悪いです。Ivyの場合、 R3TestBedCompiler
の側でJiT Compilerにenqueueする際のkeyを少し変えておくことで回避するような実装がなされています。
Angularのバージョンが2とか4とかの頃、自分で似たようなこと、すなわちJiTコンパイラの結果をspecをまたいでcacheさせてKarmaの性能を向上させる、というのをやったことがあります。
AngularはTestBedに対して、コンパイラをDIできるように設計されているので、むりやりRender 2のJiTコンパイラをflyweightパターンでラップし、cacheをもたせたわけです。
先述したように、IvyではJiTコンパイラ自体にcache管理の機能が備わっているのでいいですが、Render 2のJiTコンパイラ自体はそこまでの機能は無かったので、結構危うげな実装を自分で書いた記憶があります(そういう意味では、今回のIvyによるテスト高速化の件については「ようやく時代が俺に追いついたかー」的な上から目線な感想を少し持っていたりします)。
Before Ivyにおける単体テスト高速化の手間
Ivyを有効化していなくても、高速化する手立てはAngular 5.xの頃から用意されています。
通常、Angular CLIで生成したプロジェクトの場合、src/test.ts
は下記のようになっているはずです。
// First, initialize the Angular testing environment.
getTestBed().initTestEnvironment(
BrowserDynamicTestingModule,
platformBrowserDynamicTesting()
);
実は、この initTestEnvironment
という関数は第3引数として aotSummaries
という配列を受け取れるようになっています3。prefixにaotと入っていることからも察しが付きますが、ここにはAoTコンパイル(ngc
コマンド)の成果物である app.module.ngsummary.json
などの中身を食わせることで動作します。
上述した
before ivyにおけるテストのボトルネックである
compileModuleAndAllComponentsAsync
はJiTコンパイルを行う関数ですから、「Karmaのテスト時にもAoTを有効化させてしまえば、実行時コンパイルは行われなくなって速くなるよね」と思った
による高速化のパターンです。
なぜ *.ngsummary.json
が必要かというと、Render 2におけるAoTの場合、app.component(.ts, .html, .css)からは、app.component.js以外にも下記が生成されるようになっています。
- app.component.ngfactory.js: テンプレートの成れの果て
- app.component.css.shim.ngstyle.js: CSSの成れの果て
- app.component.metadata.json: デコレータ情報の成れの果て
問題は、app.component.jsからは上記のAoT成果物の参照が無いことです。
したがって、下記のようにTestBedを設定しても、肝心のDOM操作を担うngfactoryがどこにあるのか知る術がありません。
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [AppComponent],
});
});
AoTの結果をTestBedに教えるためには、「AppComponent
のngfactoryはここにあるんだよ」といったようなサマリ情報が別途必要となるわけです。これを担っているのが.ngsummary.json、というわけですね。
また、AoTやKarmaはarchitectという形でAngular CLIに握られているため、実際にKarmaで aotSummaries
を使うには、ejectして自分で頑張るか、architectを書き換えるしかありませんでした4。
おそらく、aotSummaries
のオプションについてはAngular 9あたりからdeprecatedにされるんじゃないかな、と思っています。そもそもIvyの世界ではapp.component.htmlやapp.component.cssの情報も全てapp.component.jsにAoTコンパイルされるため、ngsummary.json自体が出力されませんし。
また、単体テスト高速化の観点でいうと、ここまでで散々述べた通り、IvyではJiTを使ってても別になんの問題もない程度に速いので、わざわざAoTでKarmaを動かすモチベーションがありません。
実際、手元でKarma architectでAoTを使うように書き換えてもみましたが、実行時間に有意な差は見られませんでした。むしろ、architectの書き換えが秒で完了してしまい、IvyでのAoTコンパイルパイプラインのシンプルさを実感させられたのが一番のハイライトでした。
おわりに
ダラダラと書いてしまいましたが、このエントリの結論としては「Ivy有効化しておくとJiTだろうとAoTだろうと単体テストは速くなるぞ」です。
明日は @nao_y さんです!
-
この問題、Angular 4.x の頃から苦しめられていました。 https://medium.com/@Quramy/performance-angular-unit-testing-9ce30ae83e7 とかに書いたこともあったり。 ↩
-
https://github.com/angular/angular/blob/8.2.14/packages/core/src/render3/jit/module.ts#L49-L66 のあたり ↩
-
https://angular.io/api/core/testing/TestBed#inittestenvironment ↩
-
余談ですが、実際にKarmaのarchitectに
aotSummaries
を扱えるようにするためのPRを作って出したことがあるのですが「experimental過ぎるし、コンパイラ側の都合もある」という理由でリジェクトされました。今にして思うと、既にRender 3を見据えていたのかもしれません ↩