Angular Component Testing with TestBed: Zoneless, OnPush and Harnesses [2026]

Link copied
Angular Component Testing with TestBed: Zoneless, OnPush and Harnesses [2026]

Angular Component Testing with TestBed: Zoneless, OnPush and Harnesses [2026]

Angular Tutorial › Module 11: Testing › Lesson 11.3

Most Angular component tests that break for no reason break for the same few reasons: they assert before Angular has rendered, they poke at private fields instead of the DOM, they depend on the exact markup of a child component, or they were written for zone-based change detection and now run in a zoneless, OnPush world. TestBed hasn't been replaced, but the way you use it in a v22 project is noticeably different from the tests most teams still carry around.

This is lesson 11.3 of the Angular Tutorial. It builds on lesson 11.2, where you tested signals, effects and signal inputs, and on the OnPush default from Module 8. You'll learn how to configure TestBed for standalone components, how zoneless change detection behaves in tests, when to use whenStable() versus detectChanges(), how to override providers and stub children, and how Angular CDK component harnesses keep tests readable as markup changes.

Choosing the level of a component test #

Before writing any code, decide what the test is allowed to know about:

Level What you render Good for Breaks when
Class only Nothing; TestBed.inject() or new Logic that doesn't depend on the template Rarely; but it misses template bugs
Isolated DOM The component, children stubbed Inputs, outputs, conditional rendering of this component This component's markup changes
Integrated DOM The component with real children How a feature behaves as a whole Any child's markup changes
Harness-driven Real or stubbed, accessed through harnesses Stable interactions with reusable or third-party components The harness API changes (rarely)

Most of a healthy suite is isolated or harness-driven. Integrated tests are valuable for key features, but keep them few.

The basic setup for a standalone component #

Standalone components are added to the testing module through imports, not declarations:

import { TestBed, ComponentFixture } from '@angular/core/testing';
import { describe, it, expect, beforeEach } from 'vitest';
import { ProductCard } from './product-card';

describe('ProductCard', () => {
  let fixture: ComponentFixture<ProductCard>;
  let el: HTMLElement;

  beforeEach(async () => {
    TestBed.configureTestingModule({ imports: [ProductCard] });
    fixture = TestBed.createComponent(ProductCard);
    fixture.componentRef.setInput('product', { id: 'p1', name: 'Desk lamp', price: 39, stock: 0 });
    await fixture.whenStable();
    el = fixture.nativeElement;
  });

  it('shows an out-of-stock badge', () => {
    expect(el.querySelector('.badge')?.textContent).toContain('Out of stock');
  });

  it('disables the add button', () => {
    expect(el.querySelector<HTMLButtonElement>('button.add')!.disabled).toBe(true);
  });
});

A few rules apply to every TestBed test:

  • Configure, then create. After createComponent(), the testing module is frozen; calling configureTestingModule or an override* method afterwards throws.
  • No compileComponents() needed. With the CLI's unit-test builder, templates and styles are compiled as part of the build, so the old await TestBed.configureTestingModule(...).compileComponents() ritual is unnecessary.
  • createComponent() doesn't render. It creates the instance and attaches it to the DOM; the first render happens when change detection runs.

Zoneless change detection in tests #

TestBed uses zoneless change detection by default, even if zone.js happens to be loaded through polyfills. You don't need to add provideZonelessChangeDetection() to make tests zoneless; adding it is optional and simply matches what production does. If you have an older app that still runs with zones, add provideZoneChangeDetection() to the test providers instead, so tests behave like the app.

That has a direct effect on how you wait for rendering:

API What it does Use it when
await fixture.whenStable() Lets Angular run its normal scheduling, then resolves when the app is stable (no pending render, effects or tracked async work) Default choice in zoneless tests
fixture.detectChanges() Forces a synchronous change detection pass on this fixture Older tests, or when you deliberately need a synchronous pass
fixture.autoDetectChanges() Makes the fixture react to notifications automatically Rarely needed in zoneless tests, where scheduling is already automatic

Prefer whenStable(). It exercises the same path the real app uses: a signal changes, Angular schedules a render, the render happens. A test that only passes with detectChanges() is often hiding a component that doesn't notify Angular properly, and that component won't update in production either. Converting a large existing suite isn't required, though; detectChanges() still works.

OnPush and "changed without notification" #

New components are OnPush by default in v22, and TestBed checks that bindings don't change without Angular being told. If a test mutates a plain field and then renders, you'll see ExpressionChangedAfterItHasBeenCheckedError or a view that silently didn't update:

// ✗ plain field mutation: Angular is never notified
fixture.componentInstance.title = 'Updated';
await fixture.whenStable();     // nothing scheduled; DOM still shows the old title

The fix belongs in the component, not the test. Make the state a signal or an input:

// ✓ the component holds title = signal('...'), so set() schedules a render
fixture.componentInstance.title.set('Updated');
await fixture.whenStable();
expect(el.querySelector('h2')!.textContent).toBe('Updated');

For a test-only wrapper component where a plain field is genuinely fine, fixture.changeDetectorRef.markForCheck() before waiting is an acceptable escape hatch.

Interacting through the DOM #

Test what a user sees and does. Query the rendered DOM, dispatch real events, then wait:

it('filters the list as the user types', async () => {
  const input = el.querySelector<HTMLInputElement>('input[type=search]')!;
  input.value = 'lamp';
  input.dispatchEvent(new Event('input'));
  await fixture.whenStable();

  const rows = el.querySelectorAll('li.result');
  expect(rows.length).toBe(1);
  expect(rows[0].textContent).toContain('Desk lamp');
});

fixture.nativeElement with querySelector is the simplest option when tests run in a browser-like environment. fixture.debugElement.query(By.css('...')) returns a DebugElement, which adds Angular-aware helpers such as the element's injector and triggerEventHandler(); lesson 11.4 uses it for directives. Either way, select by stable hooks (a role, a data-testid, a meaningful class) rather than DOM position.

Testing inputs and outputs through a host component #

setInput() and subscribe() (lesson 11.2) test a component in isolation. A host component tests the template-facing contract as well, which catches renamed inputs, wrong aliases and broken two-way bindings:

@Component({
  imports: [QuantityPicker],
  template: `<app-quantity-picker [(qty)]="qty" [max]="5" (limitReached)="limitHits = limitHits + 1" />`,
})
class Host {
  qty = signal(4);
  limitHits = 0;
}

it('stops at max and reports it', async () => {
  const fixture = TestBed.createComponent(Host);
  await fixture.whenStable();
  const plus = fixture.nativeElement.querySelector('button.increment') as HTMLButtonElement;

  plus.click();
  await fixture.whenStable();
  plus.click();
  await fixture.whenStable();

  expect(fixture.componentInstance.qty()).toBe(5);
  expect(fixture.componentInstance.limitHits).toBe(1);
});

Overriding providers and stubbing children #

Three tools cover almost every dependency-replacement need:

Need API
Replace an app-wide service providers: [{ provide: CartApi, useValue: fakeApi }] in configureTestingModule
Replace a provider declared in the component's own providers TestBed.overrideComponent(Cmp, { set: { providers: [...] } })
Swap a heavy child component for a stub TestBed.overrideComponent(Cmp, { remove: { imports: [Child] }, add: { imports: [ChildStub] } })
@Component({ selector: 'app-price-chart', template: '' })
class PriceChartStub {
  data = input<number[]>([]);
}

beforeEach(() => {
  TestBed.configureTestingModule({
    imports: [ProductPage],
    providers: [{ provide: ProductApi, useValue: { get: vi.fn(async () => lamp) } }],
  });
  TestBed.overrideComponent(ProductPage, {
    remove: { imports: [PriceChart] },
    add: { imports: [PriceChartStub] },
  });
});

The stub keeps the same selector and inputs, so the parent's template compiles, but none of the chart library loads. Component-level providers are the trap: a provider in @Component({ providers: [...] }) wins over anything in configureTestingModule, so for those you must use overrideComponent. To read the instance a component actually received, use fixture.debugElement.injector.get(Token) rather than TestBed.inject(Token).

Component harnesses #

Querying button.increment works until someone renames the class. Component harnesses, from the Angular CDK, put that knowledge in one class that tests talk to instead of the DOM. Angular Material ships harnesses for its components, and you can write your own.

ng add @angular/cdk
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatButtonHarness } from '@angular/material/button/testing';

it('submits when the form is valid', async () => {
  const fixture = TestBed.createComponent(CheckoutForm);
  const loader = TestbedHarnessEnvironment.loader(fixture);

  const submit = await loader.getHarness(MatButtonHarness.with({ text: 'Place order' }));
  expect(await submit.isDisabled()).toBe(true);
});

Harness methods are async, and the harness environment runs change detection before reading state and after interacting, so you don't sprinkle whenStable() calls between steps. For content rendered outside the fixture, such as dialogs and menus in an overlay, use TestbedHarnessEnvironment.documentRootLoader(fixture).

A harness for your own component is a small class:

import { ComponentHarness } from '@angular/cdk/testing';

export class QuantityPickerHarness extends ComponentHarness {
  static hostSelector = 'app-quantity-picker';

  private readonly incrementButton = this.locatorFor('button.increment');
  private readonly valueEl = this.locatorFor('.value');

  async increment(): Promise<void> {
    await (await this.incrementButton()).click();
  }

  async getValue(): Promise<number> {
    return Number(await (await this.valueEl()).text());
  }
}
const picker = await loader.getHarness(QuantityPickerHarness);
await picker.increment();
expect(await picker.getValue()).toBe(2);

When the picker's markup changes, you update one harness instead of every test that uses the picker. Harnesses pay off most for shared components that many features use.

Routed components #

For components that read route parameters, RouterTestingHarness sets up a real router and navigates for you:

TestBed.configureTestingModule({
  providers: [provideRouter([{ path: 'products/:id', component: ProductPage }])],
});
const harness = await RouterTestingHarness.create();
const page = await harness.navigateByUrl('/products/p1', ProductPage);
expect(harness.routeNativeElement?.textContent).toContain('Desk lamp');

This beats mocking ActivatedRoute by hand, which tends to drift from how the router actually provides params.

Gotchas #

Symptom Cause Fix
Template is empty at the first assertion createComponent() doesn't render await fixture.whenStable() first
"Cannot configure the test module when the test module has already been instantiated" configureTestingModule or override* after createComponent() Do all configuration in beforeEach, before creating
Fake service is ignored Component declares the provider itself TestBed.overrideComponent(..., { set: { providers } })
View doesn't update after changing a field Plain field on an OnPush component Use a signal or input; markForCheck() only for test wrappers
Overlay content not found Dialogs render outside the fixture documentRootLoader(fixture) or query document.body
Tests break on every markup tweak Selectors tied to structure Harnesses for shared components; stable hooks elsewhere

What's next #

Lesson 11.4 covers the smaller building blocks: pure pipes as plain functions, attribute and structural directives through host components, services that use inject(), and HTTP code with provideHttpClientTesting() and HttpTestingController.

Try it yourself #

Pick a component test in your project that calls fixture.detectChanges() and pokes at component fields. Rewrite one test to set state through inputs or signals, wait with await fixture.whenStable(), and assert only on the DOM. If the rewritten test fails where the old one passed, you've found a component that doesn't notify Angular correctly.

YouI provided a fake OrderApi in configureTestingModule, but OrderSummary keeps calling the real one in its test. Other components pick up the fake fine.
Claude · used get_best_practicesOrderSummary lists OrderApi in its own @Component({ providers: [...] }), so it creates its own instance from the element injector and never looks at the testing module’s root provider. Replace it at the component level: call TestBed.overrideComponent(OrderSummary, { set: { providers: [{ provide: OrderApi, useValue: fakeApi }] } }) in beforeEach, before createComponent(). To assert on the instance the component actually received, read it with fixture.debugElement.injector.get(OrderApi), not TestBed.inject(OrderApi), which returns the root one. It’s also worth asking whether OrderApi needs to be component-scoped at all; if it holds no per-component state, providedIn: 'root' makes it simpler to replace.

Up next in Angular

More from this topic

View all Angular articles →

Enjoyed this article?

Get new Angular tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *