Angular 22のzoneless構成のコンポーネントテストで崩れる前提

fixture.detectChanges() を呼べば描画が反映される、という前提で書かれたテストは多い。Angular 22.2.1 の TestBed では、この呼び出しが何をするかが構成によって変わる。

@angular/core 22.2.1 の TestBedCompiler.compileTestModule(抜粋)
compileNgModuleDefs(RootScopeModule, {
  providers: [...this.rootProviderOverrides, provideZonelessChangeDetectionInternal(), TestBedApplicationErrorHandler, ...]
});

provideZonelessChangeDetectionInternal() は NgZone を NoopNgZone に差し替え、ZONELESS_ENABLED に true を入れる。テスト側が provideZoneChangeDetection() を渡さない限り、fixture は zoneless の経路を通る。実際に測ると、provider を何も渡さないテストで NgZone の実体は NoopNgZone である。

TestBed の構成による違い(jsdom 上で測定)
1) provideZoneChangeDetection / 初回: A / autoDetect 既定: false / NgZone: NgZone
3) 既定(provider 指定なし)/ 初回: A / autoDetect: true / NgZone: NoopNgZone / ZONELESS_ENABLED: true

autoDetect の既定も入れ替わる。この2つの差が、既存のテストで最初に表に出る。

detectChanges()が通る2つの経路

ComponentFixture.detectChanges() の中身は、構成で分かれている。

22.2.1 の AbstractFixture.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() のほうにある。

22.2.1 の ApplicationRef.tick
tick() {
  if (!this.zonelessEnabled) {
    this.dirtyFlags |= 1;
  }
  this._tick();
}

全体検査を表す旗(dirtyFlags |= 1)が立つのは zone 構成のときだけである。_tick() から呼ばれる synchronize() は while (this.dirtyFlags !== 0 && ...) で回るため、zoneless では dirty なビューが1つも無いと、ビューの検査自体が起きない。

既定の TestBed で素のフィールドを書き換えた場合
4) 既定 / 素のフィールド + detectChanges: A
5) 既定 / markForCheck してから detectChanges: C

4行目はフィールドを '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(...) される 出力の処理のあとに変更検知が走る
既定の TestBed で測定(テンプレートは / )
1) 初回: A / 0
2) setInput + detectChanges: B / 0
3) click を送って whenStable: B / 1
4) 素のフィールドを書き換えて whenStable: B / 1

3行目は detectChanges() を呼んでいない。zoneless では autoDetect の既定が true で、イベントの処理のあとに変更検知が予約されるため、await fixture.whenStable() だけで描画が進む。4行目は whenStable() を挟んでも反映されない。

既存のテストを移すときの書き換えは、signal か setInput へ寄せる形になる。入力を signal で宣言していないコンポーネントでも componentRef.setInput は使えるので、入力の更新をフィールドへの代入で済ませている箇所は機械的に置き換えられる。

Comment
@Reviewer: `fixture.componentInstance.items = [...]` のあとに `fixture.detectChanges()` を呼んでいますが、zoneless の TestBed ではこの呼び出しが `ApplicationRef.tick()` になり、dirty なビューが無いので検査が走りません。描画が前の状態のまま assert を通っています。`items` を signal にするか、入力なら `fixture.componentRef.setInput('items', [...])` で更新してください。

autoDetectを切る指定が通らない箇所

変更検知の回数を数えるテストや、中間状態を確かめるテストでは、自動検査を止めて書く形があった。zoneless では止められない。

22.2.1 の AbstractFixture.autoDetectChanges(抜粋)
autoDetectChanges(autoDetect = true) {
  if (!autoDetect && this.zonelessEnabled) {
    throw new Error('Cannot set autoDetect to false with zoneless change detection.');
  }
  ...
}
既定の TestBed で autoDetectChanges(false) を呼んだとき
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 の実装をそのまま呼ぶ。

22.2.1 の withFakeAsyncTestModule(抜粋)
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 が要る。

zone.js はあるが zone-testing を読み込まない場合
3) zone.js はあるが zone-testing を読み込まない場合の fakeAsync: zone-testing.js is needed for the fakeAsync() test helper but could not be found.

置き換え先は await fixture.whenStable() が基本になる。ただし待ち合わせの範囲が違う。

22.2.1 の AbstractFixture.isStable
isStable() {
  return !this.pendingTasks.hasPendingTasks;
}

whenStable() が見るのは PendingTasks だけである。zone 構成では ZoneStablePendingTask が NgZone の不安定さを PendingTasks に橋渡しするので、ゾーンの中で始めた setTimeout も待ち合わせの対象に入る。

22.2.1 の ZoneStablePendingTask.initialize(抜粋)
this.subscription.add(this.zone.onUnstable.subscribe(() => {
  NgZone.assertInAngularZone();
  task ??= this.pendingTasks.add();
}));

zoneless にはこの橋が無い。PendingTasks に載るのは、HttpClient の要求、resource の読み込み、ルーターの遷移、PendingTasks.run() で明示的に包んだ処理である。素の setTimeout は載らない。

既定の TestBed で setTimeout(10) を仕掛けてから whenStable を待った結果
7) whenStable の戻りと素の setTimeout の完了: false / false

whenStable() は待たずに解決し(待たなかった場合の戻り値は false)、タイマーは未完了である。コンポーネントの中に setTimeout を持つ実装をテストするなら、処理を PendingTasks.run() で包むか、テスト側で実時間を待つか、時間の進め方そのものをテストの道具(偽のタイマー)で置き換える。移行の作業でここを飛ばすと、待ち合わせの無いテストが不定期に落ちる形になる。

変更検知の回数に依存した検査

ApplicationRef.tick() は、開発モードで全ビューの checkNoChanges() を走らせる。

22.2.1 の ApplicationRef.tickImpl(抜粋)
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 の中で何が起きるかを検査する形に書き換える。

レビュー観点チェックリスト

zoneless 構成のテストを確かめる項目
  • テストの 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観点の「テストと検証」の群に並べてある。