Skip to the solution
MediumFrontend9 min read

What is ComponentFixtureAutoDetect and how does it affect unit tests?

773 views

The problem

Question presented to candidate: "Some of our older specs provide ComponentFixtureAutoDetect and never call detectChanges(); the newer ones never mention it. What does that token do, and how does its behaviour differ between a Zone.js project and a zoneless Angular 22 project?"

What a strong answer should cover:

  • ComponentFixtureAutoDetect is an injection token read when a fixture is created; true makes the fixture re-render automatically, like a running app would.
  • With Zone.js the default is off: you call detectChanges() after every change. Turned on, change detection runs at the end of each zone turn (events, timers, promises started inside NgZone), but a direct write from the test body is still missed.
  • Zoneless (the Angular 22 default), auto detect is on by default; signals, template events and setInput() schedule a render, and await fixture.whenStable() waits for it.
  • In zoneless mode you cannot turn it off: fixture.autoDetectChanges(false) throws; providing the token as false leaves the fixture unable to render.
  • fixture.autoDetectChanges() is the per-fixture equivalent of providing the token as true.
  • Auto detect does not remove the need to await: rendering is still asynchronous.

Clarifying questions expected:

  • "Does the project still load Zone.js, or is it zoneless?"
  • "Are the components using signals, or plain fields?"

Code / implementation expected: Yes β€” a spec that shows the default zoneless behaviour, and one Zone-based spec with the token on and off.

  • testing

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

9 min read Β· 10 sections

Read the code

AngularZoneless default: auto detect is on and cannot be turned off (Vitest, ran with ng test on Angular 22.2.3)
import { Component, signal } from '@angular/core';
import { ComponentFixtureAutoDetect, TestBed } from '@angular/core/testing';

@Component({
  selector: 'app-counter',
  template: `<button (click)="count.set(count() + 1)">+</button><span>{{ count() }}</span>`,
})
class Counter {
  count = signal(0);
}

const span = (f: { nativeElement: HTMLElement }) => f.nativeElement.querySelector('span')?.textContent;
const realWait = (ms: number) => new Promise((r) => setTimeout(r, ms));

describe('ComponentFixtureAutoDetect in a zoneless (default) TestBed', () => {
  it('auto detection is already on: signals and clicks render by themselves', async () => {
    const fixture = TestBed.createComponent(Counter);
    expect(span(fixture)).toBe(''); // elements exist, but the first render is scheduled
    await fixture.whenStable();
    expect(span(fixture)).toBe('0');

    fixture.componentInstance.count.set(41);
    await realWait(20); // no Angular call at all
    expect(span(fixture)).toBe('41');

    fixture.nativeElement.querySelector('button').click();
    expect(span(fixture)).toBe('41'); // the click marked the view dirty...
    await fixture.whenStable();
    expect(span(fixture)).toBe('42'); // ...and the scheduler rendered it
  });

  it('turning it off is not supported when zoneless', () => {
    const fixture = TestBed.createComponent(Counter);
    expect(() => fixture.autoDetectChanges(false)).toThrow(
      'Cannot set autoDetect to false with zoneless change detection.',
    );
  });

  it('the provider set to false: the component never renders', async () => {
    TestBed.configureTestingModule({
      providers: [{ provide: ComponentFixtureAutoDetect, useValue: false }],
    });
    const fixture = TestBed.createComponent(Counter);
    await fixture.whenStable();
    expect(span(fixture)).toBe(''); // still never rendered
    expect(() => fixture.detectChanges()).toThrow(/NG0100/);
  });
});
AngularZone.js workspace: ComponentFixtureAutoDetect off vs on (ran with zone.js 0.16.3 + vitest-patch)
// Runs in a workspace that loads zone.js + zone.js/testing as polyfills and
// zone.js/plugins/vitest-patch as a setup file, with Zone-based change detection.
import { ChangeDetectionStrategy, Component, provideZoneChangeDetection } from '@angular/core';
import { ComponentFixtureAutoDetect, TestBed } from '@angular/core/testing';

@Component({
  selector: 'app-greeting',
  // Classic Zone-era component: Eager, so a plain field is re-read on every check.
  changeDetection: ChangeDetectionStrategy.Eager,
  template: `<p>Hello {{ name }}</p><button (click)="rename('Grace')">rename</button>`,
})
class Greeting {
  name = 'Ada';
  rename(next: string) {
    setTimeout(() => (this.name = next), 10); // a plain field, changed later
  }
}

const p = (f: { nativeElement: HTMLElement }) => f.nativeElement.querySelector('p')?.textContent ?? '';
const click = (f: { nativeElement: HTMLElement }) => f.nativeElement.querySelector('button')!.click();

describe('ComponentFixtureAutoDetect with Zone.js', () => {
  it('off (the Zone default): you call detectChanges() after every change', async () => {
    TestBed.configureTestingModule({ providers: [provideZoneChangeDetection()] });
    const fixture = TestBed.createComponent(Greeting);
    expect(p(fixture)).toBe(''); // created, but not rendered yet
    fixture.detectChanges();
    expect(p(fixture)).toBe('Hello Ada');

    click(fixture);
    await fixture.whenStable(); // the zone timer has fired...
    expect(p(fixture)).toBe('Hello Ada'); // ...but nobody told the fixture to render
    fixture.detectChanges();
    expect(p(fixture)).toBe('Hello Grace');
  });

  it('on: every zone turn ends with change detection', async () => {
    TestBed.configureTestingModule({
      providers: [provideZoneChangeDetection(), { provide: ComponentFixtureAutoDetect, useValue: true }],
    });
    const fixture = TestBed.createComponent(Greeting);
    expect(p(fixture)).toBe(''); // not synchronous: the first render is scheduled
    await fixture.whenStable();
    expect(p(fixture)).toBe('Hello Ada');

    click(fixture); // listener runs inside NgZone, so its setTimeout is tracked
    await fixture.whenStable();
    expect(p(fixture)).toBe('Hello Grace'); // rendered with no detectChanges() call

    fixture.componentInstance.name = 'Linus'; // a direct write from the test body
    expect(p(fixture)).toBe('Hello Grace'); // is not a zone task, so autoDetect misses it
    fixture.detectChanges();
    expect(p(fixture)).toBe('Hello Linus');
  });
});

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 35 of 121 in Angular

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Angular questions
Up nextMedium

What is the async pipe and why is it useful?

Continue