skip to content

Moving an Angular test suite from zone.js to zoneless, which ComponentFixture habits break or change meaning, and how do you rewrite them?

level: seniorimportance: should knowfreq 40%

answer

  1. what decides the test's mode
  2. auto-detect cannot be turned off
  3. only notified views refresh
  4. exhaustive checks catch silent staleness

basics

~20 s

Zoneless fixtures always auto-detect, detectChanges refreshes only views Angular was notified about, and plain field assignments either stay stale on OnPush views or throw NG0100 on Eager ones. Rewrite tests to change state through signals, setInput and events, then await whenStable.

solid answer

~40 s

TestBed runs zoneless when zone.js is not loaded, or when `provideZonelessChangeDetection()` is provided. Then: fixtures auto-detect by default and `autoDetectChanges(false)` throws; `detectChanges()` becomes an `ApplicationRef.tick()` that refreshes only notified views; a plain field assignment leaves an `OnPush` view stale and makes an `Eager` view fail the dev-mode check with NG0100; and zone-only helpers such as `fakeAsync` need zone.js. The rewrite: state in signals, inputs via `setInput` or `inputBinding`, interactions via real events, then `await fixture.whenStable()`. Adding `provideCheckNoChangesConfig({ exhaustive: true })` (developer preview) to a test makes the dev-mode check treat every view as Eager, so silent `OnPush` staleness surfaces as NG0100 too.

go deeper

for a junior

Know that zoneless fixtures render automatically and that you await whenStable after changing state through signals, inputs or events.

for a middle

Explain what decides the mode, why autoDetectChanges(false) throws, and how targeted refresh treats notified and un-notified views.

for a senior

Plan and execute the migration: find field assignments, remove forced detectChanges where they mask bugs, and use exhaustive checks to surface stale OnPush views.

for a principal

Sequence the migration against the product: which suites switch first, how zone-only helpers are retired, and what the team accepts as done.

## What decides the mode Angular's zoneless guide states the rule for tests: **TestBed uses zone-based change detection when `zone.js` is loaded through the test polyfills, and runs zoneless when it is not.** Adding `provideZonelessChangeDetection()` to the testing module forces zoneless even with zone.js present. New CLI projects have not loaded zone.js since v21, so their tests are zoneless from the start; older projects move over by removing the polyfill or adding the provider. ## Habits that change meaning | Zone-based habit | What happens zoneless | Rewrite | |---|---|---| | `fixture.autoDetectChanges(false)` or relying on auto-detect being off | Fixtures auto-detect by default; `autoDetectChanges(false)` **throws** | Remove it; assert synchronously before awaiting if you need the "before" state | | `fixture.detectChanges()` after every change | Calls `ApplicationRef.tick()` over all test views but refreshes only **notified** views | Keep it where it helps, but prefer `await fixture.whenStable()` | | `componentInstance.x = 1; detectChanges()` | `OnPush` (v22 default): DOM stays stale. `Eager`: dev check throws **NG0100** | Signals for state, `setInput`/`inputBinding` for inputs | | `fakeAsync` / `tick()` around fixture work | Needs `zone.js/testing` | Real awaits, or runner fake timers (a separate subject) | ## Why plain assignments fail differently In zoneless mode, `detectChanges()` runs `ApplicationRef.tick()` in **targeted** mode: it refreshes views flagged as needing a refresh — by a signal they read, `setInput`, a template event or `markForCheck` — and skips the rest, even `Eager` ones. After that, dev mode runs `checkNoChanges` on every view: - An **`Eager`** view is included in that check. The binding now differs from what was rendered, so it throws `ExpressionChangedAfterItHasBeenCheckedError` (**NG0100**). The zoneless guide describes this case exactly: a template value updated without a change notification. - An **`OnPush`** view that was not marked dirty is skipped by the default check too, so the DOM is simply stale and nothing throws. Both outcomes point to the same production bug: the component changes state without telling Angular, and the app would not refresh either. ## Making silent staleness loud `provideCheckNoChangesConfig({ exhaustive: true })` — in **developer preview** since v20 — makes `checkNoChanges` treat every view as if it were `Eager`. With it in a test's providers, the `OnPush` case also throws NG0100 instead of passing with stale DOM. It is a diagnostic aid during a migration, not something to leave on everywhere without thought. ## A migration plan for a suite 1. **Switch the mode** for a slice of specs, by removing the zone.js polyfill from the test target or adding `provideZonelessChangeDetection()` to their testing modules. 2. **Delete `autoDetectChanges(false)`** calls; they now throw. 3. **Replace field assignments**: inputs through `fixture.componentRef.setInput(...)`, internal state through signals. 4. **Replace `detectChanges()`** with `await fixture.whenStable()` in new or touched specs. The guide notes that converting every existing `detectChanges()` is usually not worth the effort; the dev-mode check still enforces notifications. 5. **Handle zone-only helpers**: `fakeAsync`, `waitForAsync` and `tick()` depend on zone.js and need a different approach. 6. **Fix components, not tests**, when NG0100 or stale DOM appears: the test found a missing notification. ## Finding the specs to touch In a large suite, search before rewriting: - `autoDetectChanges(false)` — every hit throws once zoneless. - `componentInstance.` followed by an assignment — each is a candidate for `setInput` or a signal. - `fakeAsync(`, `waitForAsync(` and `tick(` — these need zone.js and a different approach. - Specs that call `detectChanges()` repeatedly to make something appear — often a component that never notifies Angular. Run the slice with the exhaustive check enabled first; the NG0100 list it produces is the component work queue. ## Example rewrite ```ts // Before (zone-based) fixture.componentInstance.max = 1 as any; fixture.detectChanges(); // After (zoneless) fixture.componentRef.setInput('max', 1); await fixture.whenStable(); ``` ## What stays the same - `TestBed.createComponent` still does not render synchronously. - `whenStable()` still rejects if Angular caught an application error while it was pending. - Querying through `nativeElement` and `debugElement` is unchanged.

  • Why does a zoneless detectChanges() not refresh an Eager component whose field changed?
    In zoneless mode `detectChanges()` runs `ApplicationRef.tick()` in targeted mode, which refreshes only views flagged by a notification: a signal they read, `setInput`, a template event or `markForCheck`. A plain assignment sets no flag, so the view is skipped; the dev-mode `checkNoChanges` pass then sees the changed binding and throws NG0100.
  • Should every detectChanges() call be converted to await whenStable() during a zoneless migration?
    Not necessarily. The zoneless guide says converting existing suites is usually not worth the effort, because TestBed still enforces notifications through its dev-mode check. Convert new or touched specs, and any spec where `detectChanges()` forces a render the component never scheduled, since that hides the bug the migration is meant to expose.

saying these in an interview costs you the question

  • Zoneless tests must call detectChanges because nothing renders automatically.
  • A test is zoneless only if provideZonelessChangeDetection is in its providers.
  • In zoneless mode detectChanges refreshes every view, like a global check.
  • NG0100 during migration is a test artefact; switch the check off.
  • fakeAsync keeps working unchanged after zone.js is removed from the tests.