Component Testing in Angular
Component testing in Angular uses TestBed to create a mini Angular environment for your component — compiling its template, wiring up dependencies, and enabling interaction testing.
Good component tests verify behavior visible to the user, not implementation details.
TestBed Basics
import { TestBed, ComponentFixture } from '@angular/core/testing';
import { GreetingComponent } from './greeting.component';
describe('GreetingComponent', () => {
let component: GreetingComponent;
let fixture: ComponentFixture<GreetingComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [GreetingComponent], // For standalone components
// declarations: [GreetingComponent], // For module-based components
}).compileComponents();
fixture = TestBed.createComponent(GreetingComponent);
component = fixture.componentInstance;
fixture.detectChanges(); // Trigger ngOnInit + initial change detection
});
it('should create', () => {
expect(component).toBeTruthy();
});
});imports in the testing module. For module-based components, use declarations. You don't need compileComponents() for standalone components.Querying the DOM
Access the rendered DOM via fixture.nativeElement or Angular's By utility:
import { By } from '@angular/platform-browser';
// Native DOM queries
const heading = fixture.nativeElement.querySelector('h1');
const buttons = fixture.nativeElement.querySelectorAll('button');
const text = fixture.nativeElement.textContent;
// Angular's By.css (returns DebugElement — richer API)
const debugEl = fixture.debugElement.query(By.css('.submit-btn'));
const allItems = fixture.debugElement.queryAll(By.css('li'));
// Access component via DebugElement
const btnComponent = fixture.debugElement.query(
By.directive(ButtonComponent)
).componentInstance;Testing a Simple Component
// greeting.component.ts
import { Component, Input } from '@angular/core';
@Component({
standalone: true,
selector: 'app-greeting',
template: `
<h1>Hello, {{ name }}!</h1>
<p class="subtitle">Welcome back</p>
`,
})
export class GreetingComponent {
@Input() name = 'World';
}// greeting.component.spec.ts
describe('GreetingComponent', () => {
let component: GreetingComponent;
let fixture: ComponentFixture<GreetingComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [GreetingComponent],
}).compileComponents();
fixture = TestBed.createComponent(GreetingComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('shows default greeting', () => {
const h1 = fixture.nativeElement.querySelector('h1');
expect(h1.textContent).toBe('Hello, World!');
});
it('shows custom name via Input', () => {
component.name = 'Alice';
fixture.detectChanges();
const h1 = fixture.nativeElement.querySelector('h1');
expect(h1.textContent).toBe('Hello, Alice!');
});
it('renders subtitle text', () => {
const subtitle = fixture.nativeElement.querySelector('.subtitle');
expect(subtitle.textContent).toBe('Welcome back');
});
});Testing User Interactions
// counter.component.ts
@Component({
standalone: true,
selector: 'app-counter',
template: `
<p data-testid="count">Count: {{ count }}</p>
<button (click)="increment()">+</button>
<button (click)="decrement()">-</button>
<button (click)="reset()">Reset</button>
`,
})
export class CounterComponent {
count = 0;
increment() { this.count++; }
decrement() { this.count--; }
reset() { this.count = 0; }
}// counter.component.spec.ts
describe('CounterComponent', () => {
let fixture: ComponentFixture<CounterComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CounterComponent],
}).compileComponents();
fixture = TestBed.createComponent(CounterComponent);
fixture.detectChanges();
});
function getCount(): string {
return fixture.nativeElement.querySelector('[data-testid="count"]').textContent;
}
function clickButton(text: string) {
const buttons = fixture.nativeElement.querySelectorAll('button');
const btn = Array.from(buttons).find((b: any) => b.textContent.includes(text));
(btn as HTMLElement).click();
fixture.detectChanges();
}
it('starts at 0', () => {
expect(getCount()).toContain('Count: 0');
});
it('increments on + click', () => {
clickButton('+');
expect(getCount()).toContain('Count: 1');
});
it('decrements on - click', () => {
clickButton('-');
expect(getCount()).toContain('Count: -1');
});
it('resets to 0', () => {
clickButton('+');
clickButton('+');
clickButton('Reset');
expect(getCount()).toContain('Count: 0');
});
});Testing with Service Dependencies
// user-list.component.ts
@Component({
standalone: true,
imports: [AsyncPipe, NgFor],
selector: 'app-user-list',
template: `
@if (loading) {
<p>Loading...</p>
} @else {
<ul>
@for (user of users; track user.id) {
<li>{{ user.name }}</li>
}
</ul>
}
`,
})
export class UserListComponent implements OnInit {
users: User[] = [];
loading = true;
private userService = inject(UserService);
ngOnInit() {
this.userService.getUsers().subscribe({
next: (users) => {
this.users = users;
this.loading = false;
},
});
}
}// user-list.component.spec.ts
import { of } from 'rxjs';
describe('UserListComponent', () => {
let fixture: ComponentFixture<UserListComponent>;
let mockUserService: jasmine.SpyObj<UserService>;
const mockUsers: User[] = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
];
beforeEach(async () => {
mockUserService = jasmine.createSpyObj('UserService', ['getUsers']);
mockUserService.getUsers.and.returnValue(of(mockUsers));
await TestBed.configureTestingModule({
imports: [UserListComponent],
providers: [
{ provide: UserService, useValue: mockUserService },
],
}).compileComponents();
fixture = TestBed.createComponent(UserListComponent);
fixture.detectChanges();
});
it('calls getUsers on init', () => {
expect(mockUserService.getUsers).toHaveBeenCalledTimes(1);
});
it('renders a list item for each user', () => {
const items = fixture.nativeElement.querySelectorAll('li');
expect(items.length).toBe(2);
expect(items[0].textContent).toContain('Alice');
expect(items[1].textContent).toContain('Bob');
});
it('hides loading state after data loads', () => {
const loading = fixture.nativeElement.querySelector('p');
expect(loading).toBeNull(); // loading element gone
});
});Testing Inputs and Outputs
// product-card.component.ts
@Component({
standalone: true,
selector: 'app-product-card',
template: `
<div class="card">
<h2>{{ product.name }}</h2>
<p>{{ product.price | currency }}</p>
<button (click)="addToCart.emit(product)">Add to Cart</button>
</div>
`,
})
export class ProductCardComponent {
@Input({ required: true }) product!: { name: string; price: number; id: number };
@Output() addToCart = new EventEmitter<{ name: string; price: number; id: number }>();
}describe('ProductCardComponent', () => {
let fixture: ComponentFixture<ProductCardComponent>;
let component: ProductCardComponent;
const mockProduct = { id: 1, name: 'Laptop', price: 999.99 };
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProductCardComponent, CurrencyPipe],
}).compileComponents();
fixture = TestBed.createComponent(ProductCardComponent);
component = fixture.componentInstance;
component.product = mockProduct;
fixture.detectChanges();
});
it('displays product name', () => {
const h2 = fixture.nativeElement.querySelector('h2');
expect(h2.textContent).toBe('Laptop');
});
it('emits addToCart with product on button click', () => {
let emittedProduct: any;
component.addToCart.subscribe((p) => (emittedProduct = p));
const btn = fixture.nativeElement.querySelector('button');
btn.click();
expect(emittedProduct).toEqual(mockProduct);
});
it('emits the correct product', () => {
const spy = spyOn(component.addToCart, 'emit');
fixture.nativeElement.querySelector('button').click();
expect(spy).toHaveBeenCalledWith(mockProduct);
});
});Testing Reactive Forms
// login-form.component.ts
@Component({
standalone: true,
imports: [ReactiveFormsModule],
selector: 'app-login-form',
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input formControlName="email" placeholder="Email" />
<input type="password" formControlName="password" placeholder="Password" />
<button type="submit" [disabled]="form.invalid">Login</button>
@if (form.get('email')?.errors?.['required'] && form.get('email')?.touched) {
<span class="error">Email is required</span>
}
</form>
`,
})
export class LoginFormComponent {
form = new FormGroup({
email: new FormControl('', [Validators.required, Validators.email]),
password: new FormControl('', [Validators.required, Validators.minLength(8)]),
});
submitted = false;
onSubmit() {
if (this.form.valid) {
this.submitted = true;
}
}
}describe('LoginFormComponent', () => {
let fixture: ComponentFixture<LoginFormComponent>;
let component: LoginFormComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [LoginFormComponent],
}).compileComponents();
fixture = TestBed.createComponent(LoginFormComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('submit button is disabled when form is invalid', () => {
const btn = fixture.nativeElement.querySelector('button[type="submit"]');
expect(btn.disabled).toBe(true);
});
it('submit button is enabled with valid inputs', () => {
component.form.setValue({ email: 'user@test.com', password: 'secret123' });
fixture.detectChanges();
const btn = fixture.nativeElement.querySelector('button[type="submit"]');
expect(btn.disabled).toBe(false);
});
it('shows error when email touched and empty', () => {
const emailCtrl = component.form.get('email')!;
emailCtrl.markAsTouched();
fixture.detectChanges();
const error = fixture.nativeElement.querySelector('.error');
expect(error).toBeTruthy();
expect(error.textContent).toContain('Email is required');
});
it('sets submitted flag on valid submit', () => {
component.form.setValue({ email: 'user@test.com', password: 'secret123' });
fixture.detectChanges();
fixture.nativeElement.querySelector('button').click();
fixture.detectChanges();
expect(component.submitted).toBe(true);
});
});Component Harnesses (Angular CDK)
Angular CDK testing harnesses provide a stable, implementation-agnostic way to interact with Angular Material (and other) components. They insulate tests from DOM changes.
import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatButtonHarness } from '@angular/material/button/testing';
import { MatInputHarness } from '@angular/material/input/testing';
describe('LoginComponent with harnesses', () => {
let loader: HarnessLoader;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [LoginComponent, MatFormFieldModule, MatInputModule, MatButtonModule],
}).compileComponents();
const fixture = TestBed.createComponent(LoginComponent);
loader = TestbedHarnessEnvironment.loader(fixture);
fixture.detectChanges();
});
it('button is disabled initially', async () => {
const btn = await loader.getHarness(MatButtonHarness.with({ text: 'Login' }));
expect(await btn.isDisabled()).toBe(true);
});
it('enables button after valid input', async () => {
const emailInput = await loader.getHarness(MatInputHarness.with({ placeholder: 'Email' }));
const passInput = await loader.getHarness(MatInputHarness.with({ placeholder: 'Password' }));
await emailInput.setValue('user@test.com');
await passInput.setValue('secret123');
const btn = await loader.getHarness(MatButtonHarness.with({ text: 'Login' }));
expect(await btn.isDisabled()).toBe(false);
});
});Best Practices
Test behavior, not implementation — avoid testing private methods or component internals
Use data-testid attributes for stable DOM queries that survive refactoring
Always call fixture.detectChanges() after changing component inputs or state
Mock service dependencies to keep component tests isolated
Test the full user interaction flow, not just individual methods
Use ComponentFixture.whenStable() or fakeAsync/tick for async operations
Prefer Angular CDK harnesses for Material components — they are stable across version upgrades
data-testid attributes or semantic HTML selectors (role, label) for robust queries.