skip to content

In an Angular 22 test, why can setting a field on fixture.componentInstance and calling detectChanges leave the DOM stale, and what should you do instead?

level: middleimportance: should knowfreq 52%

answer

  1. the v22 default strategy
  2. nobody told Angular
  3. componentRef marks the view dirty
  4. signals notify on write

basics

~20 s

Since v22 a component without a changeDetection value is OnPush, and assigning a plain field notifies nothing, so change detection skips the view. Drive state through signals, set inputs with fixture.componentRef.setInput, or bind them with inputBinding.

solid answer

~40 s

In Angular 22 a component without a `changeDetection` setting is `OnPush`: its view is refreshed only when it is marked dirty — by an input change through Angular, an event from its template, a signal it reads, or `markForCheck`. `fixture.componentInstance.label = 'x'` is none of these, so `detectChanges()` skips the view and the DOM keeps the old value. Fixes: keep component state in signals (`count.set(2)` notifies); set inputs with `fixture.componentRef.setInput('max', 5)`, which marks the view dirty and works for `input()` and `@Input`; or pass `bindings: [inputBinding('max', max)]` to `TestBed.createComponent` and change the signal. `setInput` is rejected on a component created with `inputBinding`.

code

ts · 8 lines
ts
const fixture = TestBed.createComponent(LimitedCounter);
fixture.componentRef.setInput('max', 1);
await fixture.whenStable();

fixture.nativeElement.querySelector('button').click();
await fixture.whenStable();

expect(fixture.nativeElement.querySelector('button').disabled).toBe(true);

go deeper

for a junior

Remember that since v22 components are OnPush by default, so change inputs with componentRef.setInput and state with signals rather than assigning fields.

for a middle

Explain what marks an OnPush view dirty, why setInput works where assignment does not, and how inputBinding and outputBinding wire a fixture reactively.

for a senior

Read stale DOM or NG0100 in a test as a notification bug the app shares, and fix components rather than papering over it with extra detectChanges calls.

for a principal

Plan how a suite written for Eager components survives the v22 default: which components get an explicit Eager during migration and which get signal-based state.

## The symptom A test sets something on the component and asserts on the DOM: ```ts fixture.componentInstance.title = 'Stock'; fixture.detectChanges(); expect(fixture.nativeElement.textContent).toContain('Stock'); // fails ``` This worked for years in zone-based suites. In Angular 22 it often fails. ## Why: OnPush is now the default Since **v22**, a component that does not set `changeDetection` is **`OnPush`**. The previous always-check behaviour is `ChangeDetectionStrategy.Eager`, and `ChangeDetectionStrategy.Default` is a deprecated alias of it. An `OnPush` view is refreshed only when something marks it for check: - an input set through Angular (a template binding, `setInput`, an `inputBinding`); - an event handled by a listener in its own template; - a signal read by its template changing; - an explicit `ChangeDetectorRef.markForCheck()`. Assigning a plain field from the test is none of these. Angular has no idea anything changed, so `detectChanges()` passes over the view and the old text stays. Nothing throws, which is what makes it confusing. ## The fixes, by what you are changing | You want to change | Do this | Why it works | |---|---|---| | Internal state | Keep it in a `signal` and write the signal | Signal writes notify every view that reads it | | An input | `fixture.componentRef.setInput('max', 5)` | Sets the input through Angular and marks the view dirty | | An input, reactively | `TestBed.createComponent(C, { bindings: [inputBinding('max', max)] })` | The binding reads your signal; `max.set(5)` updates it | | An output | `outputBinding('limitReached', handler)` in `bindings`, or subscribe to the output | Observes emissions without a host template | Using the counter with a limit: ```ts import {inputBinding, outputBinding, signal} from '@angular/core'; it('disables + at a custom limit', async () => { const max = signal(2); const reached: number[] = []; const fixture = TestBed.createComponent(LimitedCounter, { bindings: [ inputBinding('max', max), outputBinding<number>('limitReached', (n) => reached.push(n)), ], }); await fixture.whenStable(); const button: HTMLButtonElement = fixture.nativeElement.querySelector('button'); button.click(); button.click(); await fixture.whenStable(); expect(button.disabled).toBe(true); expect(reached).toEqual([2]); }); ``` ## setInput details `ComponentRef.setInput(name, value)`: - takes the input's **public** name (its alias, if it has one); - works for signal inputs (`input()`, `model()`) and decorator inputs (`@Input`); - does nothing if the value is identical to the last one it set, matching template-binding semantics; - reports an error in dev mode when the name is not an input of the component; - **throws** on a component created with `inputBinding` or `twoWayBinding` — choose one mechanism per fixture. You cannot assign a signal input directly: `max` is a read-only `InputSignal`, so `componentInstance.max = ...` replaces the property with something the template does not expect, and `InputSignal` has no `set` method. ## When the component is Eager If a component explicitly declares `ChangeDetectionStrategy.Eager`, a zone-based `detectChanges()` still refreshes it after a plain field assignment. In a zoneless test it does not: `detectChanges()` only refreshes views that were notified, and the dev-mode `checkNoChanges` pass then finds the changed binding and throws **NG0100**. The zoneless guide describes exactly this and recommends fixing the component with signals or `markForCheck()` rather than the test. ## What this means for design The stale-DOM symptom is the test telling you the truth: in the application, the same assignment from a service or a timer would not refresh an `OnPush` view either. Tests that drive components through inputs, signals and events exercise the same notification path as production, which is the point of the v22 default. ## A test host as an alternative Some teams test inputs through a tiny **host component** whose template binds the component under test: `<app-limited-counter [max]="max()" />` with `max = signal(2)` on the host. Changing the host's signal flows through a real template binding, exactly like production. It costs one extra class per spec file, and it is still useful when you need content projection or several inputs changing together. `inputBinding` gives most of the same benefit without the extra component. ## Checklist 1. Is the component `OnPush` (default) or `Eager`? 2. Is the value an input? Use `setInput` or `inputBinding`. 3. Is it internal state? Make it a signal. 4. After the change, `await fixture.whenStable()` (zoneless) or `detectChanges()` (zone-based).

  • Why can't you write fixture.componentInstance.max.set(5) for a signal input?
    `input()` creates a read-only `InputSignal`; only its parent binding may change it, so it has no `set` method. From a test, set it the way a parent would: `fixture.componentRef.setInput('max', 5)`, or create the component with `inputBinding('max', maxSignal)` and change the signal.
  • What happens if you call setInput on a component created with inputBinding?
    It throws in dev mode: 'Cannot call `setInput` on a component that is using the `inputBinding` or `twoWayBinding` functions.' A fixture's inputs are driven by one mechanism. With `inputBinding`, change the signal you passed; with plain `createComponent`, use `setInput`.

saying these in an interview costs you the question

  • Components are always checked eagerly unless you opt into OnPush.
  • fixture.detectChanges re-renders every view regardless of whether it was marked dirty.
  • Assign signal inputs directly on componentInstance to change them in a test.
  • setInput and inputBinding can be mixed on the same fixture.
  • Stale DOM after a field assignment is a test-only quirk with no production impact.