Unit Testing Components
A component is more than a class — it is class state plus the template that renders it. A complete component test therefore asserts on both: it reads and mutates the component instance, then verifies that the rendered DOM reflects that state after change detection runs. This page shows how to query the view, simulate user interaction, and drive a component through its states with realistic, runnable tests.
Anatomy of a component test
Every component test follows the same rhythm: configure a TestBed module, create a ComponentFixture, render it, then alternate between acting (mutating state or dispatching events) and asserting (reading the instance or querying the DOM). Between every act and its DOM assertion you call fixture.detectChanges() so the template re-renders.
Consider a small standalone counter that uses signals and the new control flow:
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<p class="count">Count: {{ count() }}</p>
@if (count() > 0) {
<span class="positive">positive</span>
}
<button (click)="increment()">+</button>
`,
})
export class CounterComponent {
count = signal(0);
increment() {
this.count.update((n) => n + 1);
}
}
The test creates the fixture and renders once before asserting:
import { TestBed, ComponentFixture } from '@angular/core/testing';
import { CounterComponent } from './counter.component';
describe('CounterComponent', () => {
let fixture: ComponentFixture<CounterComponent>;
let component: CounterComponent;
beforeEach(() => {
TestBed.configureTestingModule({ imports: [CounterComponent] });
fixture = TestBed.createComponent(CounterComponent);
component = fixture.componentInstance;
fixture.detectChanges(); // initial render + ngOnInit
});
it('renders the initial count', () => {
const el: HTMLElement = fixture.nativeElement;
expect(el.querySelector('.count')?.textContent).toContain('Count: 0');
});
});
A freshly created fixture has not rendered. Always call
fixture.detectChanges()before your first DOM assertion, or you will query an empty template.
Querying the rendered DOM
You have two ways to reach into the view. fixture.nativeElement is the raw root DOM node — use querySelector / querySelectorAll for plain HTML lookups. fixture.debugElement is an Angular-aware wrapper whose query(By.css(...)) understands the component tree, which is handy for inspecting directives, listeners, and child component instances.
import { By } from '@angular/platform-browser';
it('exposes the count via debugElement', () => {
const countEl = fixture.debugElement.query(By.css('.count'));
expect(countEl.nativeElement.textContent).toContain('Count: 0');
});
| Approach | Best for |
|---|---|
nativeElement.querySelector | Reading text content and attributes from plain HTML. |
debugElement.query(By.css) | Angular-aware traversal, accessing child component instances. |
debugElement.query(By.directive(X)) | Finding the element a directive is applied to. |
debugElement.queryAll(By.css) | Asserting on lists rendered with @for. |
Triggering change detection and events
Component state changes do not reach the DOM until a change detection pass runs. The canonical loop is act → detectChanges() → assert. To simulate user interaction you can call .click() on a native element, or dispatch an event through the debugElement with triggerEventHandler, which invokes the bound handler directly without touching the real DOM event system.
it('increments and reveals the positive label on click', () => {
const button = fixture.nativeElement.querySelector('button');
button.click();
fixture.detectChanges();
expect(component.count()).toBe(1);
expect(fixture.nativeElement.querySelector('.positive')).not.toBeNull();
});
it('also works via triggerEventHandler', () => {
fixture.debugElement
.query(By.css('button'))
.triggerEventHandler('click', null);
fixture.detectChanges();
expect(component.count()).toBe(1);
});
Output:
CounterComponent
✓ renders the initial count
✓ exposes the count via debugElement
✓ increments and reveals the positive label on click
✓ also works via triggerEventHandler
Test Suites: 1 passed, 1 total
Tests: 4 passed, 4 total
Driving inputs and rendered lists
For components that take inputs, set them through fixture.componentRef.setInput() rather than assigning to the instance — this routes the value through signal inputs and input transforms exactly as the framework would at runtime. Lists rendered with @for are best verified by counting the elements queryAll returns.
@Component({
selector: 'app-tag-list',
standalone: true,
template: `@for (tag of tags(); track tag) { <li class="tag">{{ tag }}</li> }`,
})
export class TagListComponent {
tags = input.required<string[]>();
}
import { input } from '@angular/core';
it('renders one li per tag', () => {
const fixture = TestBed.createComponent(TagListComponent);
fixture.componentRef.setInput('tags', ['ng', 'rxjs', 'signals']);
fixture.detectChanges();
const items = fixture.debugElement.queryAll(By.css('.tag'));
expect(items.length).toBe(3);
expect(items[1].nativeElement.textContent).toContain('rxjs');
});
Avoid setting signal inputs by writing to the component instance directly. Use
setInput()so the input’s reactivity and any transform run correctly.
Best Practices
- Render with
fixture.detectChanges()once before your first assertion, and again after every state change you want reflected in the DOM. - Assert on both class state (signals, methods) and rendered output — a passing class test can still hide a broken template.
- Prefer
setInput()over instance assignment so signal inputs and input transforms behave as they do at runtime. - Query by stable hooks (
data-testid, semantic selectors) rather than brittle CSS structure that changes with styling. - Use
triggerEventHandlerfor Angular event bindings and.click()for genuine native behavior, but stay consistent within a suite. - Keep the testing module minimal — import only the component under test and stub its dependencies to keep tests fast and isolated.