Angular Testing Guide
Comprehensive guide to testing Angular applications using Jasmine, Karma, and modern testing practices.
Overview
Angular provides a powerful testing infrastructure built on Jasmine and Karma. This guide covers testing components, services, directives, pipes, and more with TypeScript type safety.
What You'll Learn
- TestBed Configuration - Angular's testing module
- Component Testing - DOM interaction and change detection
- Service Testing - Dependency injection and HTTP
- RxJS Testing - Observables and async operations
- Routing Testing - Router and navigation
- NgRx Testing - State management testing
- E2E Testing - End-to-end with Protractor/Cypress
Setup
Default Setup (Angular CLI)
Angular CLI projects come with testing pre-configured:
# Create new Angular project with testing
ng new my-app
# Run tests
ng test
# Run tests with code coverage
ng test --code-coverage
# Run tests in headless mode (CI)
ng test --browsers=ChromeHeadless --watch=false
Manual Setup
If you need to set up testing manually:
npm install --save-dev @angular/core @angular/platform-browser-dynamic
npm install --save-dev jasmine-core karma karma-jasmine karma-chrome-launcher
npm install --save-dev @types/jasmine
karma.conf.js:
module.exports = function(config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-coverage'),
require('@angular-devkit/build-angular/plugins/karma')
],
client: {
jasmine: {
random: false
},
clearContext: false
},
coverageReporter: {
dir: require('path').join(__dirname, './coverage'),
subdir: '.',
reporters: [
{ type: 'html' },
{ type: 'text-summary' },
{ type: 'lcovonly' }
]
},
reporters: ['progress', 'coverage'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
singleRun: false,
restartOnFileChange: true
});
};
TestBed - Angular Testing Module
TestBed is Angular's primary testing utility for configuring and creating an Angular testing module.
Basic TestBed Configuration
import { TestBed } from '@angular/core/testing';
import { UserService } from './user.service';
describe('UserService', () => {
let service: UserService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [UserService]
});
service = TestBed.inject(UserService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});
TestBed with Dependencies
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { UserService } from './user.service';
import { LoggerService } from './logger.service';
describe('UserService with dependencies', () => {
let service: UserService;
let loggerSpy: jasmine.SpyObj<LoggerService>;
beforeEach(() => {
// Create spy object
const spy = jasmine.createSpyObj('LoggerService', ['log', 'error']);
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
UserService,
{ provide: LoggerService, useValue: spy }
]
});
service = TestBed.inject(UserService);
loggerSpy = TestBed.inject(LoggerService) as jasmine.SpyObj<LoggerService>;
});
it('should log user creation', () => {
service.createUser({ name: 'John', email: 'john@example.com' });
expect(loggerSpy.log).toHaveBeenCalledWith('User created');
});
});
Component Testing
Basic Component Test
counter.component.ts:
import { Component } from '@angular/core';
@Component({
selector: 'app-counter',
template: `
<div>
<p>Count: {{ count }}</p>
<button (click)="increment()">Increment</button>
<button (click)="decrement()">Decrement</button>
</div>
`
})
export class CounterComponent {
count = 0;
increment(): void {
this.count++;
}
decrement(): void {
this.count--;
}
}
counter.component.spec.ts:
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CounterComponent } from './counter.component';
describe('CounterComponent', () => {
let component: CounterComponent;
let fixture: ComponentFixture<CounterComponent>;
let compiled: HTMLElement;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [CounterComponent]
}).compileComponents();
fixture = TestBed.createComponent(CounterComponent);
component = fixture.componentInstance;
compiled = fixture.nativeElement;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should display initial count', () => {
const countText = compiled.querySelector('p')?.textContent;
expect(countText).toBe('Count: 0');
});
it('should increment count', () => {
const button = compiled.querySelectorAll('button')[0];
button.click();
fixture.detectChanges();
expect(component.count).toBe(1);
expect(compiled.querySelector('p')?.textContent).toBe('Count: 1');
});
it('should decrement count', () => {
component.count = 5;
fixture.detectChanges();
const button = compiled.querySelectorAll('button')[1];
button.click();
fixture.detectChanges();
expect(component.count).toBe(4);
expect(compiled.querySelector('p')?.textContent).toBe('Count: 4');
});
});
Component with Input/Output
user-card.component.ts:
import { Component, Input, Output, EventEmitter } from '@angular/core';
export interface User {
id: number;
name: string;
email: string;
}
@Component({
selector: 'app-user-card',
template: `
<div class="user-card">
<h3>{{ user.name }}</h3>
<p>{{ user.email }}</p>
<button (click)="onDelete()">Delete</button>
</div>
`
})
export class UserCardComponent {
@Input() user!: User;
@Output() delete = new EventEmitter<number>();
onDelete(): void {
this.delete.emit(this.user.id);
}
}
user-card.component.spec.ts:
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { UserCardComponent, User } from './user-card.component';
describe('UserCardComponent', () => {
let component: UserCardComponent;
let fixture: ComponentFixture<UserCardComponent>;
let compiled: HTMLElement;
const mockUser: User = {
id: 1,
name: 'John Doe',
email: 'john@example.com'
};
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [UserCardComponent]
}).compileComponents();
fixture = TestBed.createComponent(UserCardComponent);
component = fixture.componentInstance;
compiled = fixture.nativeElement;
});
it('should display user information', () => {
component.user = mockUser;
fixture.detectChanges();
expect(compiled.querySelector('h3')?.textContent).toBe('John Doe');
expect(compiled.querySelector('p')?.textContent).toBe('john@example.com');
});
it('should emit delete event with user id', () => {
component.user = mockUser;
let emittedId: number | undefined;
component.delete.subscribe((id: number) => {
emittedId = id;
});
const button = compiled.querySelector('button');
button?.click();
expect(emittedId).toBe(1);
});
});
Component with Service Dependency
user-list.component.ts:
import { Component, OnInit } from '@angular/core';
import { UserService } from './user.service';
import { User } from './user.model';
@Component({
selector: 'app-user-list',
template: `
<div>
<h2>Users</h2>
<div *ngIf="loading">Loading...</div>
<div *ngIf="error" class="error">{{ error }}</div>
<ul *ngIf="!loading && !error">
<li *ngFor="let user of users">{{ user.name }}</li>
</ul>
</div>
`
})
export class UserListComponent implements OnInit {
users: User[] = [];
loading = false;
error: string | null = null;
constructor(private userService: UserService) {}
ngOnInit(): void {
this.loading = true;
this.userService.getUsers().subscribe({
next: (users) => {
this.users = users;
this.loading = false;
},
error: (err) => {
this.error = 'Failed to load users';
this.loading = false;
}
});
}
}
user-list.component.spec.ts:
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { UserListComponent } from './user-list.component';
import { UserService } from './user.service';
import { User } from './user.model';
describe('UserListComponent', () => {
let component: UserListComponent;
let fixture: ComponentFixture<UserListComponent>;
let userService: jasmine.SpyObj<UserService>;
let compiled: HTMLElement;
const mockUsers: User[] = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' }
];
beforeEach(async () => {
const userServiceSpy = jasmine.createSpyObj('UserService', ['getUsers']);
await TestBed.configureTestingModule({
declarations: [UserListComponent],
providers: [
{ provide: UserService, useValue: userServiceSpy }
]
}).compileComponents();
fixture = TestBed.createComponent(UserListComponent);
component = fixture.componentInstance;
compiled = fixture.nativeElement;
userService = TestBed.inject(UserService) as jasmine.SpyObj<UserService>;
});
it('should display loading state initially', () => {
userService.getUsers.and.returnValue(of(mockUsers));
component.ngOnInit();
expect(component.loading).toBe(true);
});
it('should display users after loading', () => {
userService.getUsers.and.returnValue(of(mockUsers));
fixture.detectChanges();
expect(component.loading).toBe(false);
expect(component.users.length).toBe(2);
const listItems = compiled.querySelectorAll('li');
expect(listItems.length).toBe(2);
expect(listItems[0].textContent).toBe('Alice');
expect(listItems[1].textContent).toBe('Bob');
});
it('should display error message on failure', () => {
userService.getUsers.and.returnValue(
throwError(() => new Error('Network error'))
);
fixture.detectChanges();
expect(component.loading).toBe(false);
expect(component.error).toBe('Failed to load users');
expect(compiled.querySelector('.error')?.textContent).toBe('Failed to load users');
});
});
Service Testing
Basic Service Test
calculator.service.ts:
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class CalculatorService {
add(a: number, b: number): number {
return a + b;
}
subtract(a: number, b: number): number {
return a - b;
}
divide(a: number, b: number): number {
if (b === 0) {
throw new Error('Cannot divide by zero');
}
return a / b;
}
}
calculator.service.spec.ts:
import { TestBed } from '@angular/core/testing';
import { CalculatorService } from './calculator.service';
describe('CalculatorService', () => {
let service: CalculatorService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(CalculatorService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should add two numbers', () => {
expect(service.add(5, 3)).toBe(8);
});
it('should subtract two numbers', () => {
expect(service.subtract(10, 4)).toBe(6);
});
it('should throw error when dividing by zero', () => {
expect(() => service.divide(10, 0)).toThrowError('Cannot divide by zero');
});
});
HTTP Service Testing
user.service.ts:
import { Injectable } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
export interface User {
id: number;
name: string;
email: string;
}
@Injectable({
providedIn: 'root'
})
export class UserService {
private apiUrl = 'https://api.example.com/users';
constructor(private http: HttpClient) {}
getUsers(): Observable<User[]> {
return this.http.get<User[]>(this.apiUrl).pipe(
catchError(this.handleError)
);
}
getUserById(id: number): Observable<User> {
return this.http.get<User>(`${this.apiUrl}/${id}`).pipe(
catchError(this.handleError)
);
}
createUser(user: Omit<User, 'id'>): Observable<User> {
return this.http.post<User>(this.apiUrl, user).pipe(
catchError(this.handleError)
);
}
updateUser(id: number, user: Partial<User>): Observable<User> {
return this.http.patch<User>(`${this.apiUrl}/${id}`, user).pipe(
catchError(this.handleError)
);
}
deleteUser(id: number): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`).pipe(
catchError(this.handleError)
);
}
private handleError(error: HttpErrorResponse): Observable<never> {
let errorMessage = 'An error occurred';
if (error.error instanceof ErrorEvent) {
errorMessage = `Error: ${error.error.message}`;
} else {
errorMessage = `Server returned code ${error.status}`;
}
return throwError(() => new Error(errorMessage));
}
}
user.service.spec.ts:
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { UserService, User } from './user.service';
describe('UserService', () => {
let service: UserService;
let httpMock: HttpTestingController;
const mockUsers: User[] = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' }
];
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [UserService]
});
service = TestBed.inject(UserService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify(); // Verify no outstanding requests
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should retrieve all users', (done) => {
service.getUsers().subscribe({
next: (users) => {
expect(users.length).toBe(2);
expect(users).toEqual(mockUsers);
done();
}
});
const req = httpMock.expectOne('https://api.example.com/users');
expect(req.request.method).toBe('GET');
req.flush(mockUsers);
});
it('should retrieve user by id', (done) => {
const mockUser = mockUsers[0];
service.getUserById(1).subscribe({
next: (user) => {
expect(user).toEqual(mockUser);
done();
}
});
const req = httpMock.expectOne('https://api.example.com/users/1');
expect(req.request.method).toBe('GET');
req.flush(mockUser);
});
it('should create a new user', (done) => {
const newUser = { name: 'Charlie', email: 'charlie@example.com' };
const createdUser = { id: 3, ...newUser };
service.createUser(newUser).subscribe({
next: (user) => {
expect(user).toEqual(createdUser);
done();
}
});
const req = httpMock.expectOne('https://api.example.com/users');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual(newUser);
req.flush(createdUser);
});
it('should update a user', (done) => {
const updates = { name: 'Alice Updated' };
const updatedUser = { ...mockUsers[0], ...updates };
service.updateUser(1, updates).subscribe({
next: (user) => {
expect(user.name).toBe('Alice Updated');
done();
}
});
const req = httpMock.expectOne('https://api.example.com/users/1');
expect(req.request.method).toBe('PATCH');
expect(req.request.body).toEqual(updates);
req.flush(updatedUser);
});
it('should delete a user', (done) => {
service.deleteUser(1).subscribe({
next: () => {
done();
}
});
const req = httpMock.expectOne('https://api.example.com/users/1');
expect(req.request.method).toBe('DELETE');
req.flush(null);
});
it('should handle errors', (done) => {
service.getUsers().subscribe({
error: (error) => {
expect(error.message).toContain('Server returned code 500');
done();
}
});
const req = httpMock.expectOne('https://api.example.com/users');
req.flush('Server error', { status: 500, statusText: 'Server Error' });
});
});
Testing RxJS Observables
Testing Async Operations
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { of, delay } from 'rxjs';
describe('Async Observable Testing', () => {
it('should test observable with fakeAsync', fakeAsync(() => {
let result: string | undefined;
of('Hello').pipe(delay(1000)).subscribe((value) => {
result = value;
});
// Initially undefined
expect(result).toBeUndefined();
// Fast-forward time by 1000ms
tick(1000);
// Now we have the result
expect(result).toBe('Hello');
}));
});
Testing with Jasmine Marble Testing
npm install --save-dev jasmine-marbles
import { cold, hot, getTestScheduler } from 'jasmine-marbles';
import { map } from 'rxjs/operators';
describe('Marble Testing', () => {
it('should map values', () => {
const source = cold('--a--b--c--|', { a: 1, b: 2, c: 3 });
const expected = cold('--x--y--z--|', { x: 2, y: 4, z: 6 });
const result = source.pipe(map((x) => x * 2));
expect(result).toBeObservable(expected);
});
it('should handle errors', () => {
const source = cold('--a--b--#', { a: 1, b: 2 }, new Error('error'));
const expected = cold('--x--y--#', { x: 2, y: 4 }, new Error('error'));
const result = source.pipe(map((x) => x * 2));
expect(result).toBeObservable(expected);
});
});
Testing Directives
highlight.directive.ts:
import { Directive, ElementRef, Input, OnChanges } from '@angular/core';
@Directive({
selector: '[appHighlight]'
})
export class HighlightDirective implements OnChanges {
@Input() appHighlight = '';
constructor(private el: ElementRef) {}
ngOnChanges(): void {
this.el.nativeElement.style.backgroundColor = this.appHighlight || 'yellow';
}
}
highlight.directive.spec.ts:
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HighlightDirective } from './highlight.directive';
@Component({
template: `<div [appHighlight]="color">Test</div>`
})
class TestComponent {
color = '';
}
describe('HighlightDirective', () => {
let component: TestComponent;
let fixture: ComponentFixture<TestComponent>;
let div: HTMLElement;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [HighlightDirective, TestComponent]
}).compileComponents();
fixture = TestBed.createComponent(TestComponent);
component = fixture.componentInstance;
div = fixture.nativeElement.querySelector('div');
});
it('should apply default yellow background', () => {
fixture.detectChanges();
expect(div.style.backgroundColor).toBe('yellow');
});
it('should apply custom color', () => {
component.color = 'lightblue';
fixture.detectChanges();
expect(div.style.backgroundColor).toBe('lightblue');
});
});
Testing Pipes
truncate.pipe.ts:
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'truncate'
})
export class TruncatePipe implements PipeTransform {
transform(value: string, limit: number = 50, trail: string = '...'): string {
if (!value) return '';
if (value.length <= limit) return value;
return value.substring(0, limit) + trail;
}
}
truncate.pipe.spec.ts:
import { TruncatePipe } from './truncate.pipe';
describe('TruncatePipe', () => {
let pipe: TruncatePipe;
beforeEach(() => {
pipe = new TruncatePipe();
});
it('should create an instance', () => {
expect(pipe).toBeTruthy();
});
it('should return empty string for null/undefined', () => {
expect(pipe.transform('')).toBe('');
});
it('should not truncate short strings', () => {
const text = 'Hello World';
expect(pipe.transform(text)).toBe('Hello World');
});
it('should truncate long strings with default limit', () => {
const text = 'A'.repeat(100);
const result = pipe.transform(text);
expect(result.length).toBe(53); // 50 + '...'
expect(result.endsWith('...')).toBe(true);
});
it('should truncate with custom limit', () => {
const text = 'Hello World, this is a test';
const result = pipe.transform(text, 10);
expect(result).toBe('Hello Worl...');
});
it('should truncate with custom trail', () => {
const text = 'Hello World, this is a test';
const result = pipe.transform(text, 10, '---');
expect(result).toBe('Hello Worl---');
});
});
Testing Forms
Template-Driven Forms
login-form.component.ts:
import { Component } from '@angular/core';
@Component({
selector: 'app-login-form',
template: `
<form #loginForm="ngForm" (ngSubmit)="onSubmit()">
<input
name="email"
type="email"
[(ngModel)]="email"
required
email
#emailField="ngModel"
/>
<div *ngIf="emailField.invalid && emailField.touched">
<span *ngIf="emailField.errors?.['required']">Email is required</span>
<span *ngIf="emailField.errors?.['email']">Invalid email</span>
</div>
<input
name="password"
type="password"
[(ngModel)]="password"
required
minlength="6"
#passwordField="ngModel"
/>
<div *ngIf="passwordField.invalid && passwordField.touched">
<span *ngIf="passwordField.errors?.['required']">Password is required</span>
<span *ngIf="passwordField.errors?.['minlength']">Min 6 characters</span>
</div>
<button type="submit" [disabled]="loginForm.invalid">Login</button>
</form>
`
})
export class LoginFormComponent {
email = '';
password = '';
submitted = false;
onSubmit(): void {
this.submitted = true;
}
}
login-form.component.spec.ts:
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import { LoginFormComponent } from './login-form.component';
describe('LoginFormComponent (Template-Driven)', () => {
let component: LoginFormComponent;
let fixture: ComponentFixture<LoginFormComponent>;
let compiled: HTMLElement;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [LoginFormComponent],
imports: [FormsModule]
}).compileComponents();
fixture = TestBed.createComponent(LoginFormComponent);
component = fixture.componentInstance;
compiled = fixture.nativeElement;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should disable submit button when form is invalid', () => {
const button = compiled.querySelector('button') as HTMLButtonElement;
expect(button.disabled).toBe(true);
});
it('should enable submit button when form is valid', async () => {
component.email = 'test@example.com';
component.password = 'password123';
fixture.detectChanges();
await fixture.whenStable();
const button = compiled.querySelector('button') as HTMLButtonElement;
expect(button.disabled).toBe(false);
});
it('should show email validation errors', async () => {
const emailInput = compiled.querySelector('input[name="email"]') as HTMLInputElement;
emailInput.value = '';
emailInput.dispatchEvent(new Event('input'));
emailInput.dispatchEvent(new Event('blur'));
fixture.detectChanges();
await fixture.whenStable();
expect(compiled.textContent).toContain('Email is required');
});
});
Reactive Forms
register-form.component.ts:
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
@Component({
selector: 'app-register-form',
template: `
<form [formGroup]="registerForm" (ngSubmit)="onSubmit()">
<input formControlName="email" type="email" />
<div *ngIf="email?.invalid && email?.touched">
<span *ngIf="email?.errors?.['required']">Email is required</span>
<span *ngIf="email?.errors?.['email']">Invalid email</span>
</div>
<input formControlName="password" type="password" />
<div *ngIf="password?.invalid && password?.touched">
<span *ngIf="password?.errors?.['required']">Password is required</span>
<span *ngIf="password?.errors?.['minlength']">Min 8 characters</span>
</div>
<button type="submit" [disabled]="registerForm.invalid">Register</button>
</form>
`
})
export class RegisterFormComponent implements OnInit {
registerForm!: FormGroup;
submitted = false;
constructor(private fb: FormBuilder) {}
ngOnInit(): void {
this.registerForm = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]]
});
}
get email() {
return this.registerForm.get('email');
}
get password() {
return this.registerForm.get('password');
}
onSubmit(): void {
if (this.registerForm.valid) {
this.submitted = true;
}
}
}
register-form.component.spec.ts:
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { RegisterFormComponent } from './register-form.component';
describe('RegisterFormComponent (Reactive)', () => {
let component: RegisterFormComponent;
let fixture: ComponentFixture<RegisterFormComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [RegisterFormComponent],
imports: [ReactiveFormsModule]
}).compileComponents();
fixture = TestBed.createComponent(RegisterFormComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create form with email and password controls', () => {
expect(component.registerForm.contains('email')).toBe(true);
expect(component.registerForm.contains('password')).toBe(true);
});
it('should make email required', () => {
const control = component.registerForm.get('email');
control?.setValue('');
expect(control?.valid).toBe(false);
expect(control?.hasError('required')).toBe(true);
});
it('should validate email format', () => {
const control = component.registerForm.get('email');
control?.setValue('invalid-email');
expect(control?.hasError('email')).toBe(true);
control?.setValue('valid@example.com');
expect(control?.hasError('email')).toBe(false);
});
it('should validate password minimum length', () => {
const control = component.registerForm.get('password');
control?.setValue('short');
expect(control?.hasError('minlength')).toBe(true);
control?.setValue('longpassword');
expect(control?.hasError('minlength')).toBe(false);
});
it('should be invalid when empty', () => {
expect(component.registerForm.valid).toBe(false);
});
it('should be valid when filled correctly', () => {
component.registerForm.patchValue({
email: 'test@example.com',
password: 'password123'
});
expect(component.registerForm.valid).toBe(true);
});
it('should submit when form is valid', () => {
component.registerForm.patchValue({
email: 'test@example.com',
password: 'password123'
});
component.onSubmit();
expect(component.submitted).toBe(true);
});
it('should not submit when form is invalid', () => {
component.registerForm.patchValue({
email: 'invalid',
password: 'short'
});
component.onSubmit();
expect(component.submitted).toBe(false);
});
});
Testing Routing
app-routing.module.ts:
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { HomeComponent } from './home/home.component';
import { AboutComponent } from './about/about.component';
import { UserDetailComponent } from './user-detail/user-detail.component';
const routes: Routes = [
{ path: '', component: HomeComponent },
{ path: 'about', component: AboutComponent },
{ path: 'users/:id', component: UserDetailComponent }
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule {}
navigation.component.spec.ts:
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { Location } from '@angular/common';
import { RouterTestingModule } from '@angular/router/testing';
import { NavigationComponent } from './navigation.component';
import { HomeComponent } from '../home/home.component';
import { AboutComponent } from '../about/about.component';
describe('Router Navigation', () => {
let router: Router;
let location: Location;
let fixture: ComponentFixture<NavigationComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [NavigationComponent, HomeComponent, AboutComponent],
imports: [
RouterTestingModule.withRoutes([
{ path: '', component: HomeComponent },
{ path: 'about', component: AboutComponent }
])
]
}).compileComponents();
router = TestBed.inject(Router);
location = TestBed.inject(Location);
fixture = TestBed.createComponent(NavigationComponent);
});
it('should navigate to home', async () => {
await router.navigate(['']);
expect(location.path()).toBe('');
});
it('should navigate to about page', async () => {
await router.navigate(['about']);
expect(location.path()).toBe('/about');
});
});
Testing Route Parameters:
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';
import { of } from 'rxjs';
import { UserDetailComponent } from './user-detail.component';
describe('UserDetailComponent with Route Params', () => {
let component: UserDetailComponent;
let fixture: ComponentFixture<UserDetailComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [UserDetailComponent],
providers: [
{
provide: ActivatedRoute,
useValue: {
params: of({ id: '123' }),
snapshot: { params: { id: '123' } }
}
}
]
}).compileComponents();
fixture = TestBed.createComponent(UserDetailComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should get user id from route params', () => {
expect(component.userId).toBe('123');
});
});
Testing NgRx (State Management)
Testing Reducers
user.reducer.ts:
import { createReducer, on } from '@ngrx/store';
import { loadUsers, loadUsersSuccess, loadUsersFailure } from './user.actions';
export interface UserState {
users: any[];
loading: boolean;
error: string | null;
}
export const initialState: UserState = {
users: [],
loading: false,
error: null
};
export const userReducer = createReducer(
initialState,
on(loadUsers, (state) => ({ ...state, loading: true, error: null })),
on(loadUsersSuccess, (state, { users }) => ({
...state,
users,
loading: false
})),
on(loadUsersFailure, (state, { error }) => ({
...state,
loading: false,
error
}))
);
user.reducer.spec.ts:
import { userReducer, initialState } from './user.reducer';
import { loadUsers, loadUsersSuccess, loadUsersFailure } from './user.actions';
describe('User Reducer', () => {
it('should return initial state', () => {
const action = { type: 'Unknown' };
const state = userReducer(undefined, action);
expect(state).toBe(initialState);
});
it('should set loading to true on loadUsers', () => {
const action = loadUsers();
const state = userReducer(initialState, action);
expect(state.loading).toBe(true);
expect(state.error).toBe(null);
});
it('should load users on loadUsersSuccess', () => {
const users = [{ id: 1, name: 'Alice' }];
const action = loadUsersSuccess({ users });
const state = userReducer(initialState, action);
expect(state.users).toEqual(users);
expect(state.loading).toBe(false);
});
it('should set error on loadUsersFailure', () => {
const error = 'Failed to load';
const action = loadUsersFailure({ error });
const state = userReducer(initialState, action);
expect(state.error).toBe(error);
expect(state.loading).toBe(false);
});
});
Testing Effects
user.effects.ts:
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { of } from 'rxjs';
import { map, catchError, switchMap } from 'rxjs/operators';
import { UserService } from './user.service';
import { loadUsers, loadUsersSuccess, loadUsersFailure } from './user.actions';
@Injectable()
export class UserEffects {
loadUsers$ = createEffect(() =>
this.actions$.pipe(
ofType(loadUsers),
switchMap(() =>
this.userService.getUsers().pipe(
map((users) => loadUsersSuccess({ users })),
catchError((error) => of(loadUsersFailure({ error: error.message })))
)
)
)
);
constructor(private actions$: Actions, private userService: UserService) {}
}
user.effects.spec.ts:
import { TestBed } from '@angular/core/testing';
import { provideMockActions } from '@ngrx/effects/testing';
import { Observable, of, throwError } from 'rxjs';
import { UserEffects } from './user.effects';
import { UserService } from './user.service';
import { loadUsers, loadUsersSuccess, loadUsersFailure } from './user.actions';
describe('UserEffects', () => {
let actions$: Observable<any>;
let effects: UserEffects;
let userService: jasmine.SpyObj<UserService>;
const mockUsers = [{ id: 1, name: 'Alice', email: 'alice@example.com' }];
beforeEach(() => {
const userServiceSpy = jasmine.createSpyObj('UserService', ['getUsers']);
TestBed.configureTestingModule({
providers: [
UserEffects,
provideMockActions(() => actions$),
{ provide: UserService, useValue: userServiceSpy }
]
});
effects = TestBed.inject(UserEffects);
userService = TestBed.inject(UserService) as jasmine.SpyObj<UserService>;
});
it('should return loadUsersSuccess on success', (done) => {
userService.getUsers.and.returnValue(of(mockUsers));
actions$ = of(loadUsers());
effects.loadUsers$.subscribe((action) => {
expect(action).toEqual(loadUsersSuccess({ users: mockUsers }));
done();
});
});
it('should return loadUsersFailure on error', (done) => {
const error = new Error('Network error');
userService.getUsers.and.returnValue(throwError(() => error));
actions$ = of(loadUsers());
effects.loadUsers$.subscribe((action) => {
expect(action).toEqual(loadUsersFailure({ error: 'Network error' }));
done();
});
});
});
Testing Selectors
user.selectors.ts:
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { UserState } from './user.reducer';
export const selectUserState = createFeatureSelector<UserState>('users');
export const selectAllUsers = createSelector(
selectUserState,
(state) => state.users
);
export const selectUsersLoading = createSelector(
selectUserState,
(state) => state.loading
);
export const selectUsersError = createSelector(
selectUserState,
(state) => state.error
);
export const selectUserById = (id: number) =>
createSelector(selectAllUsers, (users) => users.find((user) => user.id === id));
user.selectors.spec.ts:
import { UserState } from './user.reducer';
import { selectAllUsers, selectUsersLoading, selectUserById } from './user.selectors';
describe('User Selectors', () => {
const mockState: { users: UserState } = {
users: {
users: [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' }
],
loading: false,
error: null
}
};
it('should select all users', () => {
const result = selectAllUsers(mockState);
expect(result.length).toBe(2);
expect(result).toEqual(mockState.users.users);
});
it('should select loading state', () => {
const result = selectUsersLoading(mockState);
expect(result).toBe(false);
});
it('should select user by id', () => {
const result = selectUserById(1)(mockState);
expect(result).toEqual({ id: 1, name: 'Alice', email: 'alice@example.com' });
});
it('should return undefined for non-existent user', () => {
const result = selectUserById(999)(mockState);
expect(result).toBeUndefined();
});
});
E2E Testing
Protractor (Deprecated)
Note: Protractor is deprecated. Consider using Cypress or Playwright instead.
Cypress for Angular
npm install --save-dev cypress @cypress/schematic
ng add @cypress/schematic
cypress/e2e/app.cy.ts:
describe('My App', () => {
beforeEach(() => {
cy.visit('/');
});
it('should display welcome message', () => {
cy.contains('Welcome to my-app');
});
it('should navigate to about page', () => {
cy.get('a[href="/about"]').click();
cy.url().should('include', '/about');
cy.contains('About Us');
});
it('should fill and submit form', () => {
cy.get('input[name="email"]').type('test@example.com');
cy.get('input[name="password"]').type('password123');
cy.get('button[type="submit"]').click();
cy.contains('Login successful');
});
it('should display user list', () => {
cy.intercept('GET', '/api/users', { fixture: 'users.json' });
cy.visit('/users');
cy.get('.user-card').should('have.length', 3);
});
});
Best Practices
1. Use TestBed Properly
// ✅ Good - Reset TestBed between tests
beforeEach(async () => {
await TestBed.configureTestingModule({
// configuration
}).compileComponents();
});
// ❌ Avoid - Reusing TestBed across tests
2. Use Spies for Dependencies
// ✅ Good - Use jasmine spies
const userServiceSpy = jasmine.createSpyObj('UserService', ['getUsers']);
userServiceSpy.getUsers.and.returnValue(of(mockUsers));
// ❌ Avoid - Real dependencies in unit tests
3. Test Component Logic, Not Implementation
// ✅ Good - Test behavior
it('should display users when loaded', () => {
component.users = mockUsers;
fixture.detectChanges();
expect(compiled.querySelectorAll('.user').length).toBe(2);
});
// ❌ Avoid - Testing private methods
it('should call private method', () => {
component['privateMethod'](); // Don't test private methods
});
4. Use detectChanges() Wisely
// ✅ Good - Call detectChanges after changes
component.title = 'New Title';
fixture.detectChanges();
expect(compiled.querySelector('h1')?.textContent).toBe('New Title');
// ❌ Avoid - Forgetting detectChanges
component.title = 'New Title';
expect(compiled.querySelector('h1')?.textContent).toBe('New Title'); // Fails!
5. Clean Up Subscriptions
// ✅ Good - Use done callback for async
it('should load users', (done) => {
service.getUsers().subscribe({
next: (users) => {
expect(users.length).toBe(2);
done();
}
});
});
// ✅ Good - Use fakeAsync/tick
it('should load users', fakeAsync(() => {
let result;
service.getUsers().subscribe((users) => {
result = users;
});
tick();
expect(result.length).toBe(2);
}));
Common Testing Patterns
Pattern: Page Object Model
// user-list.page.ts
export class UserListPage {
constructor(private fixture: ComponentFixture<UserListComponent>) {}
get users() {
return this.fixture.nativeElement.querySelectorAll('.user-card');
}
get loadingSpinner() {
return this.fixture.nativeElement.querySelector('.loading');
}
get errorMessage() {
return this.fixture.nativeElement.querySelector('.error')?.textContent;
}
clickUser(index: number): void {
this.users[index].click();
}
}
// Usage in test
describe('UserListComponent', () => {
let page: UserListPage;
beforeEach(() => {
page = new UserListPage(fixture);
});
it('should display users', () => {
expect(page.users.length).toBe(3);
});
});
Pattern: Test Helpers
// test-helpers.ts
export function createMockUser(overrides?: Partial<User>): User {
return {
id: 1,
name: 'Test User',
email: 'test@example.com',
...overrides
};
}
export function triggerClick(element: HTMLElement): void {
element.click();
element.dispatchEvent(new Event('click'));
}
// Usage
const user = createMockUser({ name: 'Alice' });
Resources
Official Documentation
Tools
- Jasmine - Testing framework
- Karma - Test runner
- Protractor - E2E (deprecated)
- Cypress - Modern E2E testing
- Playwright - E2E testing alternative
- jasmine-marbles - RxJS testing
Learning Resources
Next Steps
After mastering Angular testing:
- Explore E2E Testing with Cypress or Playwright
- Learn Performance Testing for Angular apps
- Practice Accessibility Testing with axe-core
- Study Visual Regression Testing with Percy or Chromatic
- Master NgRx Testing for complex state management