Testing Angular Components: Inputs, Outputs, Template Bindings, and ChangeDetectionStrategy
Angular component tests use TestBed to mount a component in a real Angular environment, then query the DOM to assert on rendered output and trigger user interactions. This guide covers the full component testing API: querying the DOM, testing @Input bindings, asserting on @Output events, handling ChangeDetectionStrategy.OnPush, and testing content projection with ng-content.
Key Takeaways
Use By.css() for semantic queries, nativeElement.querySelector for simple ones. fixture.debugElement.query(By.css('.class')) gives you an Angular DebugElement with access to the component's injector. nativeElement.querySelector gives you raw DOM.
@Input changes require detectChanges() after assignment. Setting component.title = 'New' doesn't update the DOM. Angular waits for change detection. Call fixture.detectChanges() after every input change.
OnPush components only update when inputs change or async events fire. Directly mutating state on an OnPush component won't trigger rendering. Use component.myInput = newValue (new reference) then detectChanges().
Spy on @Output by subscribing before triggering the action. Create a spy, subscribe to the EventEmitter, trigger the action, then assert on the spy.
DebugElement.triggerEventHandler('click', null) fires Angular event handlers. It's more reliable than nativeElement.click() because it goes through Angular's event delegation.
Test Setup
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { DebugElement } from '@angular/core';
import { UserCardComponent } from './user-card.component';
describe('UserCardComponent', () => {
let component: UserCardComponent;
let fixture: ComponentFixture<UserCardComponent>;
let el: DebugElement;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [UserCardComponent],
}).compileComponents();
fixture = TestBed.createComponent(UserCardComponent);
component = fixture.componentInstance;
el = fixture.debugElement;
});
});Querying the DOM
// By CSS selector (returns DebugElement)
const heading = el.query(By.css('h2'));
const buttons = el.queryAll(By.css('button'));
// By directive
const links = el.queryAll(By.directive(RouterLink));
// Direct DOM access
const nativeHeading = fixture.nativeElement.querySelector('h2');
const allButtons = fixture.nativeElement.querySelectorAll('button');
// Text content
expect(heading.nativeElement.textContent).toBe('Alice');
expect(heading.nativeElement.textContent.trim()).toBe('Alice'); // saferBest practice: prefer data-testid attributes over CSS selectors:
<button data-testid="delete-btn" (click)="onDelete()">Delete</button>const deleteBtn = el.query(By.css('[data-testid="delete-btn"]'));Testing @Input Properties
@Component({
selector: 'app-progress-bar',
template: `
<div class="progress" [class.complete]="value >= 100">
<div class="fill" [style.width.%]="value"></div>
<span>{{ value }}%</span>
</div>
`,
})
export class ProgressBarComponent {
@Input() value = 0;
}describe('ProgressBarComponent', () => {
let component: ProgressBarComponent;
let fixture: ComponentFixture<ProgressBarComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ProgressBarComponent],
}).compileComponents();
fixture = TestBed.createComponent(ProgressBarComponent);
component = fixture.componentInstance;
});
it('shows the percentage as text', () => {
component.value = 42;
fixture.detectChanges(); // required after input change
const span = fixture.nativeElement.querySelector('span');
expect(span.textContent).toBe('42%');
});
it('sets the fill width to match the value', () => {
component.value = 75;
fixture.detectChanges();
const fill = fixture.nativeElement.querySelector('.fill');
expect(fill.style.width).toBe('75%');
});
it('adds complete class when value is 100', () => {
component.value = 100;
fixture.detectChanges();
const progress = fixture.nativeElement.querySelector('.progress');
expect(progress.classList).toContain('complete');
});
it('does not add complete class below 100', () => {
component.value = 99;
fixture.detectChanges();
const progress = fixture.nativeElement.querySelector('.progress');
expect(progress.classList).not.toContain('complete');
});
});Testing @Output Events
@Component({
selector: 'app-counter',
template: `
<div>
<span data-testid="count">{{ count }}</span>
<button data-testid="increment" (click)="increment()">+</button>
<button data-testid="decrement" (click)="decrement()">-</button>
</div>
`,
})
export class CounterComponent {
@Input() initialValue = 0;
@Output() countChange = new EventEmitter<number>();
count = 0;
ngOnInit() {
this.count = this.initialValue;
}
increment() {
this.count++;
this.countChange.emit(this.count);
}
decrement() {
if (this.count > 0) {
this.count--;
this.countChange.emit(this.count);
}
}
}it('emits countChange with new value on increment', () => {
const emittedValues: number[] = [];
component.countChange.subscribe((val: number) => emittedValues.push(val));
component.initialValue = 5;
fixture.detectChanges();
const incrementBtn = fixture.nativeElement.querySelector('[data-testid="increment"]');
incrementBtn.click();
fixture.detectChanges();
expect(emittedValues).toEqual([6]);
});
it('does not emit below zero', () => {
const emittedValues: number[] = [];
component.countChange.subscribe((val: number) => emittedValues.push(val));
component.initialValue = 0;
fixture.detectChanges();
fixture.nativeElement.querySelector('[data-testid="decrement"]').click();
fixture.detectChanges();
expect(emittedValues).toHaveSize(0);
expect(component.count).toBe(0);
});Using triggerEventHandler
triggerEventHandler fires Angular's event bindings rather than native DOM events:
el.query(By.css('[data-testid="increment"]'))
.triggerEventHandler('click', null);
fixture.detectChanges();This is more reliable for custom events that Angular handles (like (customEvent)="handler()") where native DOM click() wouldn't fire the Angular binding.
Testing ChangeDetectionStrategy.OnPush
OnPush components only re-render when their @Input references change. This is great for performance but requires care in tests:
@Component({
selector: 'app-user-list',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div *ngFor="let user of users" [data-testid]="'user-' + user.id">
{{ user.name }}
</div>
`,
})
export class UserListComponent {
@Input() users: User[] = [];
}it('re-renders when the users input changes', () => {
component.users = [{ id: '1', name: 'Alice' }];
fixture.detectChanges();
expect(fixture.nativeElement.querySelectorAll('[data-testid]')).toHaveSize(1);
// OnPush requires a NEW reference — mutating the array won't trigger re-render
component.users = [...component.users, { id: '2', name: 'Bob' }];
fixture.detectChanges();
expect(fixture.nativeElement.querySelectorAll('[data-testid]')).toHaveSize(2);
});
it('does NOT re-render when array is mutated in-place', () => {
component.users = [{ id: '1', name: 'Alice' }];
fixture.detectChanges();
// Mutating the array — OnPush won't detect this
component.users.push({ id: '2', name: 'Bob' });
fixture.detectChanges();
// Still shows only 1 user — OnPush didn't detect the mutation
expect(fixture.nativeElement.querySelectorAll('[data-testid]')).toHaveSize(1);
});Testing Content Projection
@Component({
selector: 'app-panel',
template: `
<div class="panel">
<div class="panel-header"><ng-content select="[slot=header]"></ng-content></div>
<div class="panel-body"><ng-content></ng-content></div>
</div>
`,
})
export class PanelComponent {}Use a wrapper component to test content projection:
@Component({
template: `
<app-panel>
<h2 slot="header">Panel Title</h2>
<p>Panel body content</p>
</app-panel>
`,
})
class TestHostComponent {}
describe('PanelComponent content projection', () => {
let fixture: ComponentFixture<TestHostComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [PanelComponent, TestHostComponent],
}).compileComponents();
fixture = TestBed.createComponent(TestHostComponent);
fixture.detectChanges();
});
it('projects header content', () => {
const header = fixture.nativeElement.querySelector('.panel-header h2');
expect(header.textContent).toBe('Panel Title');
});
it('projects body content', () => {
const body = fixture.nativeElement.querySelector('.panel-body p');
expect(body.textContent).toBe('Panel body content');
});
});Testing Pipes in Templates
If your component uses a pipe, import it in the test module:
import { CurrencyPipe } from '@angular/common';
await TestBed.configureTestingModule({
declarations: [ProductComponent, CurrencyPipe],
}).compileComponents();Or import CommonModule if you need multiple common pipes:
imports: [CommonModule],Running Component Tests
ng test <span class="hljs-comment"># all tests, watch mode
ng <span class="hljs-built_in">test --include=<span class="hljs-string">"**/*.component.spec.ts" <span class="hljs-comment"># component tests only
ng <span class="hljs-built_in">test --no-watch --code-coverage <span class="hljs-comment"># CI run with coverageProduction Testing
Component tests verify isolated behavior. They don't test the deployed application — how your Angular app performs with real APIs, real users, and real network conditions.
HelpMeTest tests your deployed Angular application continuously, running automated browser tests 24/7. The free tier includes 10 tests with 5-minute check intervals.