Angular 22のzoneless構成のコンポーネントテストで崩れる前提
Angular 22のzoneless構成のコンポーネントテストで崩れる前提
fixture.detectChanges() を呼べば描画が反映される、という前提で書かれたテストは多い。Angular 22.2.1 の TestBed では、この呼び出しが何をするかが構成によって変わる。
compileNgModuleDefs(RootScopeModule, {
providers: [...this.rootProviderOverrides, provideZonelessChangeDetectionInternal(), TestBedApplicationErrorHandler, ...]
});provideZonelessChangeDetectionInternal() は NgZone を NoopNgZone に差し替え、ZONELESS_ENABLED に true を入れる。テスト側が provideZoneChangeDetection() を渡さない限り、fixture は zoneless の経路を通る。実際に測ると、provider を何も渡さないテストで NgZone の実体は NoopNgZone である。
1) provideZoneChangeDetection / 初回: A / autoDetect 既定: false / NgZone: NgZone
3) 既定(provider 指定なし)/ 初回: A / autoDetect: true / NgZone: NoopNgZone / ZONELESS_ENABLED: trueautoDetect の既定も入れ替わる。この2つの差が、既存のテストで最初に表に出る。
detectChanges()が通る2つの経路
ComponentFixture.detectChanges() の中身は、構成で分かれている。
if (this.zonelessEnabled) {
try {
this._testAppRef.includeAllTestViews = true;
this._appRef.tick();
} finally {
this._testAppRef.includeAllTestViews = false;
}
} else {
this._ngZone.run(() => {
this.rootEffectScheduler.flush();
this.changeDetectorRef.detectChanges();
this.checkNoChanges();
});
}zoneless では ApplicationRef.tick() を呼ぶ。includeAllTestViews は、ApplicationRef.allViews が自動検査の対象だけでなく作成済みの全 fixture を含むようにする切り替えである。問題は tick() のほうにある。
tick() {
if (!this.zonelessEnabled) {
this.dirtyFlags |= 1;
}
this._tick();
}全体検査を表す旗(dirtyFlags |= 1)が立つのは zone 構成のときだけである。_tick() から呼ばれる synchronize() は while (this.dirtyFlags !== 0 && ...) で回るため、zoneless では dirty なビューが1つも無いと、ビューの検査自体が起きない。
4) 既定 / 素のフィールド + detectChanges: A
5) 既定 / markForCheck してから detectChanges: C4行目はフィールドを 'B' に書き換えてから detectChanges() を呼んだ結果である。描画は 'A' のまま動かない。markForCheck() を挟めば反映される。zone 構成でも同じフィールドの書き換えは反映されないが、理由が違う。あちらは changeDetectorRef.detectChanges() が fixture のホストビューを検査し、その下にある被験コンポーネントは OnPush(22 の既定)で dirty ではないため飛ばされる。zoneless では、ホストビューも含めて何も検査されない。
落ちないことが扱いにくい。detectChanges() は例外を投げず、expect が古い DOM を読んで通る場合がある。テストが緑のまま、確かめたい更新を確かめていない状態になる。
ビューをdirtyにする経路
反映させる手は4つある。どれも「ビューを dirty にする」という1点に帰着する。
| 操作 | 反映されるか | 機構 |
|---|---|---|
componentInstance.field = x |
されない | 変更検知に通知が無い |
componentInstance.sig.set(x) |
される | signal の書き込みがビューを dirty にする |
componentRef.setInput('name', x) |
される | 入力の更新がビューを dirty にする |
nativeElement.dispatchEvent(...) |
される | 出力の処理のあとに変更検知が走る |
1) 初回: A / 0
2) setInput + detectChanges: B / 0
3) click を送って whenStable: B / 1
4) 素のフィールドを書き換えて whenStable: B / 13行目は detectChanges() を呼んでいない。zoneless では autoDetect の既定が true で、イベントの処理のあとに変更検知が予約されるため、await fixture.whenStable() だけで描画が進む。4行目は whenStable() を挟んでも反映されない。
既存のテストを移すときの書き換えは、signal か setInput へ寄せる形になる。入力を signal で宣言していないコンポーネントでも componentRef.setInput は使えるので、入力の更新をフィールドへの代入で済ませている箇所は機械的に置き換えられる。
@Reviewer: `fixture.componentInstance.items = [...]` のあとに `fixture.detectChanges()` を呼んでいますが、zoneless の TestBed ではこの呼び出しが `ApplicationRef.tick()` になり、dirty なビューが無いので検査が走りません。描画が前の状態のまま assert を通っています。`items` を signal にするか、入力なら `fixture.componentRef.setInput('items', [...])` で更新してください。autoDetectを切る指定が通らない箇所
変更検知の回数を数えるテストや、中間状態を確かめるテストでは、自動検査を止めて書く形があった。zoneless では止められない。
autoDetectChanges(autoDetect = true) {
if (!autoDetect && this.zonelessEnabled) {
throw new Error('Cannot set autoDetect to false with zoneless change detection.');
}
...
}5) autoDetectChanges(false): Cannot set autoDetect to false with zoneless change detection.ComponentFixtureAutoDetect を false で provide しても、AbstractFixture の autoDetect の初期値は変わるが、この経路は例外で止まる。中間状態を見たいテストは、変更検知を止めるのではなく、TestBed.tick() を呼ぶ位置で区切る形に変える。TestBed.tick() と TestBed.flushEffects() は同じ実装で、どちらも includeAllTestViews を立てて ApplicationRef.tick() を呼ぶ。
fakeAsyncとtickが置けない構成
fakeAsync は zone.js の実装をそのまま呼ぶ。
const fakeAsyncTestModule = getFakeAsyncTestModule();
if (!fakeAsyncTestModule) {
throw new Error(`zone-testing.js is needed for the fakeAsync() test helper but could not be found.`);
}zoneless へ移す作業で zone.js/testing の読み込みを外すと、fakeAsync を使ったテストはこの例外で落ちる。zone.js 本体だけを残しても同じで、fakeAsync・tick・flush・flushMicrotasks・discardPeriodicTasks は zone-testing.js が要る。
3) zone.js はあるが zone-testing を読み込まない場合の fakeAsync: zone-testing.js is needed for the fakeAsync() test helper but could not be found.置き換え先は await fixture.whenStable() が基本になる。ただし待ち合わせの範囲が違う。
isStable() {
return !this.pendingTasks.hasPendingTasks;
}whenStable() が見るのは PendingTasks だけである。zone 構成では ZoneStablePendingTask が NgZone の不安定さを PendingTasks に橋渡しするので、ゾーンの中で始めた setTimeout も待ち合わせの対象に入る。
this.subscription.add(this.zone.onUnstable.subscribe(() => {
NgZone.assertInAngularZone();
task ??= this.pendingTasks.add();
}));zoneless にはこの橋が無い。PendingTasks に載るのは、HttpClient の要求、resource の読み込み、ルーターの遷移、PendingTasks.run() で明示的に包んだ処理である。素の setTimeout は載らない。
7) whenStable の戻りと素の setTimeout の完了: false / falsewhenStable() は待たずに解決し(待たなかった場合の戻り値は false)、タイマーは未完了である。コンポーネントの中に setTimeout を持つ実装をテストするなら、処理を PendingTasks.run() で包むか、テスト側で実時間を待つか、時間の進め方そのものをテストの道具(偽のタイマー)で置き換える。移行の作業でここを飛ばすと、待ち合わせの無いテストが不定期に落ちる形になる。
変更検知の回数に依存した検査
ApplicationRef.tick() は、開発モードで全ビューの checkNoChanges() を走らせる。
this.synchronize();
if (typeof ngDevMode === 'undefined' || ngDevMode) {
for (let view of this.allViews) {
view.checkNoChanges();
}
}NG0100(検査後に式の値が変わった)は zoneless でも出る。detectChanges(false) を渡した場合は changeDetectorRef.checkNoChanges を一時的に空の関数へ差し替えて検査を飛ばす形で、ApplicationRef 側の検査もその間は何もしない。
一方で、変更検知が走る回数は構成で変わる。zone 構成では非同期処理の完了ごとに走り、zoneless では dirty になった分だけが予約されてまとまる。描画の回数や ngDoCheck の呼び出し回数を数えるテストは、移行で値が変わる。回数そのものを固定するのではなく、ngDoCheck の中で何が起きるかを検査する形に書き換える。
レビュー観点チェックリスト
- テストの provider に
provideZoneChangeDetection()が無い場合、zoneless の経路を通る前提で読めているか componentInstanceのフィールドへ代入してdetectChanges()を呼ぶ形が残っていないか- 入力の更新が
componentRef.setInputか signal の書き込みになっているか detectChanges()を呼ばずにawait fixture.whenStable()で足りる箇所に、不要な呼び出しが残っていないかautoDetectChanges(false)に依存したテストが残っていないか(例外になる)fakeAsyncとtickを使うテストが、zone-testing.jsの読み込みと対応しているかwhenStable()で待てない処理(素のsetTimeout、追跡外の Promise)を待ち合わせの対象にしていないか- コンポーネント側の遅延処理が
PendingTasks.run()で包まれているか - 変更検知や
ngDoCheckの回数を固定した検査が残っていないか TestBed.getLastFixture()で済む箇所を、グローバル変数で引き回していないか@deferを含むテストがDeferBlockFixture経由になっているか- OnPush 既定を踏まえ、入力の更新と
detectChanges()の順序が合っているか
おわりに
Angular 22.2.1 の TestBed は、何も指定しなければ zoneless の provider を積む。fixture.detectChanges() は ApplicationRef.tick() になり、tick() は zoneless で全体検査の旗を立てない。したがって dirty なビューが無ければ何も検査されず、例外も出ない。
テストが緑のまま検査をしていない状態になりうるので、更新の経路を signal か setInput に寄せる作業が移行の中心になる。イベントの送出は zoneless でも変更検知を予約するため、detectChanges() を省いて whenStable() で待つ形に短くできる箇所もある。
待ち合わせの範囲は、zone 構成より狭い。whenStable() が見るのは PendingTasks だけで、NgZone との橋渡しが無いためである。fakeAsync と tick は zone.js の実装に依存しているので、zone-testing.js を外した時点で使えない。この2つが、既存のテストで書き換えの量が出る箇所になる。
変更検知の既定そのものはAngular 22でOnPushが既定になった後に残るEagerをどう見抜くかで扱った。テストと検証の観点の全体はAngular 22のコンポーネント設計をレビュー視点で整理する100観点の「テストと検証」の群に並べてある。