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?
answer
- the v22 default strategy
- nobody told Angular
- componentRef marks the view dirty
- signals notify on write
basics
~20 sSince 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 sIn 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 linesconst 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
Remember that since v22 components are OnPush by default, so change inputs with componentRef.setInput and state with signals rather than assigning fields.
Explain what marks an OnPush view dirty, why setInput works where assignment does not, and how inputBinding and outputBinding wire a fixture reactively.
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.
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.