Angular Component Testing with TestBed: Zoneless, OnPush and Harnesses [2026]
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; callingconfigureTestingModuleor anoverride*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 oldawait 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.
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
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.


