Angular Testing Study Guide
A comprehensive reference for testing Angular applications with Jasmine, Karma, and TestBed โ Angular 16/17 edition.
This guide covers all essential testing patterns you'll encounter in real Angular projects. Each topic includes realistic source code alongside its full spec file, with inline comments explaining the why behind every assertion.
๐ฌ Jasmine
The test framework. Provides describe, it, expect, beforeEach, afterEach, and matchers like toBe, toEqual, toHaveBeenCalled. Runs BDD-style test suites.
โ๏ธ Karma
The test runner. Launches a real browser (Chrome), executes your Jasmine specs, and reports results. Configured via karma.conf.js. Run with ng test.
๐ ๏ธ TestBed
Angular's primary testing utility. Creates a mini NgModule for your test, compiles components, and provides dependency injection. Always configure in beforeEach.
๐ Key Imports
@angular/core/testing โ TestBed, ComponentFixture@angular/common/http/testing โ HttpClientTestingModule@angular/router/testing โ RouterTestingModule
Variables & Component Properties
Testing default values, public/private properties, and computed getters. You verify that a component starts in the right state before any interaction occurs โ the foundation of all component tests.
// user-profile.component.ts
import { Component, OnInit } from '@angular/core';
export interface User {
id: number;
name: string;
role: 'admin' | 'user' | 'guest';
}
@Component({
selector: 'app-user-profile',
template: `{{ title }}
{{ user?.name }}
`
})
export class UserProfileComponent implements OnInit {
// Public properties โ directly accessible in tests
title = 'User Profile';
isLoading = false;
users: User[] = [];
// Private โ test behavior via public API (methods/template)
private maxRetries = 3;
private sessionToken = '';
// Computed getter โ test the output
get displayName(): string {
return this.currentUser
? `${this.currentUser.name} (${this.currentUser.role})`
: 'Guest';
}
currentUser: User | null = null;
ngOnInit(): void {
this.isLoading = true;
}
setUser(user: User): void {
this.currentUser = user;
this.isLoading = false;
}
clearUser(): void {
this.currentUser = null;
}
}
// user-profile.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { UserProfileComponent, User } from './user-profile.component';
describe('UserProfileComponent โ Properties', () => {
let component: UserProfileComponent;
let fixture: ComponentFixture<UserProfileComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [UserProfileComponent]
}).compileComponents();
fixture = TestBed.createComponent(UserProfileComponent);
component = fixture.componentInstance;
// Note: Do NOT call fixture.detectChanges() yet
// so we can test initial (raw) default values.
});
// โโ Happy Path โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should have correct default property values', () => {
// Verify primitive defaults before any lifecycle runs
expect(component.title).toBe('User Profile');
expect(component.isLoading).toBeFalse();
expect(component.users).toEqual([]); // deep-equal for arrays
expect(component.currentUser).toBeNull();
});
it('should set isLoading=true on ngOnInit', () => {
fixture.detectChanges(); // triggers ngOnInit
expect(component.isLoading).toBeTrue();
});
it('should update currentUser and clear loading when setUser() called', () => {
const mockUser: User = { id: 1, name: 'Sindhu', role: 'admin' };
component.setUser(mockUser);
// Both state changes should happen atomically
expect(component.currentUser).toEqual(mockUser);
expect(component.isLoading).toBeFalse();
});
// โโ Getter / Computed Value โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should return "Guest" from displayName when no user set', () => {
expect(component.displayName).toBe('Guest');
});
it('should include name and role in displayName', () => {
component.currentUser = { id: 2, name: 'Ravi', role: 'user' };
expect(component.displayName).toBe('Ravi (user)');
});
// โโ Edge Case โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should clear currentUser on clearUser()', () => {
component.currentUser = { id: 1, name: 'Test', role: 'guest' };
component.clearUser();
expect(component.currentUser).toBeNull();
// Getter should fall back to "Guest" again
expect(component.displayName).toBe('Guest');
});
// โโ Type Check โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should be an instance of UserProfileComponent', () => {
expect(component).toBeInstanceOf(UserProfileComponent);
});
});
- Don't call
fixture.detectChanges()before testing default values โ it triggersngOnInit. - Use
toBe()for primitives (strict ===) andtoEqual()for objects/arrays (deep equality). - Private properties can't be directly accessed in tests. Test them indirectly via public methods or getters.
- Use
toBeFalse()/toBeTrue()instead oftoBe(false)โ they give clearer failure messages.
Component Basics
Setting up TestBed, creating ComponentFixture, detecting changes, and testing Input/Output bindings. TestBed creates a real Angular module in the test environment โ understanding it is the most critical skill.
// counter.component.ts
import { Component, Input, Output, EventEmitter } from '@angular/core';
@Component({
selector: 'app-counter',
template: `
<div class="counter">
<h2>{{ label }}</h2>
<span data-testid="count-display">{{ count }}</span>
<button (click)="increment()" [disabled]="count >= max">+</button>
<button (click)="decrement()" [disabled]="count <= 0">-</button>
<button (click)="reset()">Reset</button>
</div>
`
})
export class CounterComponent {
@Input() label = 'Counter';
@Input() max = 10;
@Output() countChanged = new EventEmitter<number>();
count = 0;
increment(): void {
if (this.count < this.max) {
this.count++;
this.countChanged.emit(this.count);
}
}
decrement(): void {
if (this.count > 0) {
this.count--;
this.countChanged.emit(this.count);
}
}
reset(): void {
this.count = 0;
this.countChanged.emit(0);
}
}
// counter.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { CounterComponent } from './counter.component';
describe('CounterComponent โ TestBed Basics', () => {
let component: CounterComponent;
let fixture: ComponentFixture<CounterComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [CounterComponent]
// NO providers needed โ no services used
}).compileComponents();
fixture = TestBed.createComponent(CounterComponent);
component = fixture.componentInstance;
fixture.detectChanges(); // triggers ngOnInit + initial render
});
// โโ Component Creation โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should create the component', () => {
// Basic sanity check โ always include this
expect(component).toBeTruthy();
});
// โโ DOM Queries โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should display initial count of 0 in the template', () => {
const display = fixture.debugElement.query(By.css('[data-testid="count-display"]'));
// .nativeElement.textContent reads actual DOM text
expect(display.nativeElement.textContent.trim()).toBe('0');
});
// โโ @Input() Binding โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should display custom label from @Input', () => {
component.label = 'Score';
fixture.detectChanges(); // re-render after property change
const h2 = fixture.nativeElement.querySelector('h2');
expect(h2.textContent).toBe('Score');
});
it('should disable increment button when count reaches max', () => {
component.max = 2;
component.count = 2;
fixture.detectChanges();
const btn = fixture.nativeElement.querySelector('button');
expect(btn.disabled).toBeTrue();
});
// โโ @Output() Binding โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should emit countChanged when increment is called', () => {
const emittedValues: number[] = [];
// Subscribe to the EventEmitter directly
component.countChanged.subscribe((v: number) => emittedValues.push(v));
component.increment();
expect(emittedValues).toEqual([1]);
component.increment();
expect(emittedValues).toEqual([1, 2]);
});
// โโ User Interaction via DOM Click โโโโโโโโโโโโโโโโโโโโโโโโ
it('should increment count when + button is clicked', () => {
const buttons = fixture.nativeElement.querySelectorAll('button');
buttons[0].click(); // + button
fixture.detectChanges(); // sync component โ DOM
const display = fixture.debugElement.query(By.css('[data-testid="count-display"]'));
expect(display.nativeElement.textContent.trim()).toBe('1');
});
// โโ Edge Case โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should not go below 0 when decrement called at 0', () => {
component.decrement();
expect(component.count).toBe(0);
});
it('should not exceed max when increment called at max', () => {
component.count = component.max;
component.increment();
expect(component.count).toBe(component.max); // unchanged
});
afterEach(() => {
// fixture.destroy() is called automatically by Karma
// Only needed if you have manual subscriptions to clean up
});
});
- Always
await TestBed.configureTestingModule(...).compileComponents()for components with external templates. For inline templates it's still good practice. fixture.detectChanges()must be called after each property change you want reflected in the DOM.- Use
By.css()from@angular/platform-browserfor Angular-aware DOM queries. Usefixture.nativeElement.querySelector()for simple cases. - Prefer
data-testidattributes for selecting test elements โ they survive refactors better than CSS classes.
Template Bindings
Testing interpolation, *ngIf, *ngFor, class bindings, and attribute bindings. The template is part of your component's public API โ its DOM output must be verified, not just the component class.
// task-list.component.ts
import { Component, OnInit } from '@angular/core';
export interface Task {
id: number;
title: string;
completed: boolean;
priority: 'low' | 'medium' | 'high';
}
@Component({
selector: 'app-task-list',
template: `
<h1>{{ pageTitle }}</h1>
<p *ngIf="tasks.length === 0" data-testid="empty-msg">
No tasks yet!
</p>
<ul *ngIf="tasks.length > 0">
<li *ngFor="let task of tasks; trackBy: trackById"
[class.completed]="task.completed"
[attr.data-priority]="task.priority"
data-testid="task-item">
{{ task.title }}
<span *ngIf="task.completed">โ</span>
</li>
</ul>
<button
*ngIf="showClearAll"
(click)="clearTasks()"
data-testid="clear-btn">
Clear All
</button>
`
})
export class TaskListComponent implements OnInit {
pageTitle = 'My Tasks';
tasks: Task[] = [];
showClearAll = false;
ngOnInit(): void {
this.tasks = [
{ id: 1, title: 'Write tests', completed: true, priority: 'high' },
{ id: 2, title: 'Review PR', completed: false, priority: 'medium' }
];
this.showClearAll = this.tasks.length > 0;
}
clearTasks(): void {
this.tasks = [];
this.showClearAll = false;
}
trackById(_: number, task: Task): number {
return task.id;
}
}
// task-list.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { CommonModule } from '@angular/common';
import { TaskListComponent } from './task-list.component';
describe('TaskListComponent โ Template Bindings', () => {
let component: TaskListComponent;
let fixture: ComponentFixture<TaskListComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [TaskListComponent],
imports: [CommonModule] // Required for *ngIf, *ngFor
}).compileComponents();
fixture = TestBed.createComponent(TaskListComponent);
component = fixture.componentInstance;
});
// โโ Interpolation โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should render the page title via interpolation', () => {
fixture.detectChanges();
const h1 = fixture.nativeElement.querySelector('h1');
expect(h1.textContent).toBe('My Tasks');
});
// โโ *ngIf โ empty state โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should show empty message when tasks array is empty', () => {
component.tasks = []; // override before detectChanges
fixture.detectChanges();
const msg = fixture.debugElement.query(By.css('[data-testid="empty-msg"]'));
expect(msg).toBeTruthy(); // element exists in DOM
});
it('should hide empty message when tasks exist', () => {
fixture.detectChanges(); // ngOnInit loads tasks
const msg = fixture.debugElement.query(By.css('[data-testid="empty-msg"]'));
expect(msg).toBeNull(); // *ngIf removed element from DOM
});
// โโ *ngFor โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should render correct number of task items', () => {
fixture.detectChanges();
const items = fixture.debugElement.queryAll(By.css('[data-testid="task-item"]'));
// ngOnInit pushes 2 tasks
expect(items.length).toBe(2);
});
it('should render task titles in each list item', () => {
fixture.detectChanges();
const items = fixture.debugElement.queryAll(By.css('[data-testid="task-item"]'));
expect(items[0].nativeElement.textContent).toContain('Write tests');
expect(items[1].nativeElement.textContent).toContain('Review PR');
});
// โโ [class] binding โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should apply "completed" CSS class to completed tasks', () => {
fixture.detectChanges();
const items = fixture.debugElement.queryAll(By.css('[data-testid="task-item"]'));
// First task has completed=true
expect(items[0].nativeElement.classList).toContain('completed');
// Second task has completed=false
expect(items[1].nativeElement.classList).not.toContain('completed');
});
// โโ [attr] binding โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should set data-priority attribute correctly', () => {
fixture.detectChanges();
const items = fixture.debugElement.queryAll(By.css('[data-testid="task-item"]'));
expect(items[0].nativeElement.getAttribute('data-priority')).toBe('high');
});
// โโ *ngIf with button โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should show Clear All button when tasks exist', () => {
fixture.detectChanges();
const btn = fixture.debugElement.query(By.css('[data-testid="clear-btn"]'));
expect(btn).toBeTruthy();
});
it('should hide list and show empty msg after clearTasks()', () => {
fixture.detectChanges();
component.clearTasks();
fixture.detectChanges(); // sync after state change
const items = fixture.debugElement.queryAll(By.css('[data-testid="task-item"]'));
const msg = fixture.debugElement.query(By.css('[data-testid="empty-msg"]'));
expect(items.length).toBe(0);
expect(msg).toBeTruthy();
});
});
*ngIfphysically adds/removes elements from DOM. Usequery(...)and check if result isnullvs truthy.- For
*ngFor, usequeryAll(By.css(...))and check.length, not the component array directly. - Always call
fixture.detectChanges()after mutating component state to sync the DOM. CommonModulemust be imported in TestBed if using*ngIf/*ngFordirectives in a non-standalone component.
Services (No HTTP)
Injecting services via TestBed and writing isolated unit tests for business logic. Services are the backbone of Angular apps โ testing them in isolation (without components) is fast and reliable.
// cart.service.ts
import { Injectable } from '@angular/core';
export interface CartItem {
productId: number;
name: string;
price: number;
quantity: number;
}
@Injectable({ providedIn: 'root' })
export class CartService {
private items: CartItem[] = [];
getItems(): CartItem[] {
return [...this.items]; // return copy to prevent mutation
}
addItem(item: CartItem): void {
const existing = this.items.find(i => i.productId === item.productId);
if (existing) {
existing.quantity += item.quantity;
} else {
this.items.push({ ...item });
}
}
removeItem(productId: number): void {
this.items = this.items.filter(i => i.productId !== productId);
}
getTotalPrice(): number {
return this.items.reduce((sum, i) => sum + i.price * i.quantity, 0);
}
clearCart(): void {
this.items = [];
}
getItemCount(): number {
return this.items.reduce((sum, i) => sum + i.quantity, 0);
}
}
// cart.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { CartService, CartItem } from './cart.service';
describe('CartService', () => {
let service: CartService;
const mockItem: CartItem = {
productId: 1, name: 'Angular Book', price: 29.99, quantity: 1
};
beforeEach(() => {
TestBed.configureTestingModule({});
// For simple services: TestBed.inject() is preferred over new CartService()
// because it respects DI and works with dependencies
service = TestBed.inject(CartService);
});
// โโ Happy Path โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should start with an empty cart', () => {
expect(service.getItems()).toEqual([]);
expect(service.getItemCount()).toBe(0);
expect(service.getTotalPrice()).toBe(0);
});
it('should add a new item to the cart', () => {
service.addItem(mockItem);
const items = service.getItems();
expect(items.length).toBe(1);
expect(items[0].name).toBe('Angular Book');
});
it('should increase quantity when adding an existing item', () => {
service.addItem(mockItem);
service.addItem({ ...mockItem, quantity: 2 }); // same productId
const items = service.getItems();
// Should NOT add a second entry โ should merge
expect(items.length).toBe(1);
expect(items[0].quantity).toBe(3);
});
it('should calculate total price correctly', () => {
service.addItem({ productId: 1, name: 'Book', price: 10, quantity: 2 });
service.addItem({ productId: 2, name: 'Pen', price: 5, quantity: 3 });
// 10*2 + 5*3 = 20 + 15 = 35
expect(service.getTotalPrice()).toBe(35);
});
// โโ Removal โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should remove item by productId', () => {
service.addItem(mockItem);
service.addItem({ productId: 2, name: 'Pen', price: 1, quantity: 1 });
service.removeItem(1);
const items = service.getItems();
expect(items.length).toBe(1);
expect(items[0].productId).toBe(2);
});
it('should not throw when removing non-existent item', () => {
expect(() => service.removeItem(999)).not.toThrow();
});
// โโ Edge Cases โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should clear all items from cart', () => {
service.addItem(mockItem);
service.clearCart();
expect(service.getItems()).toEqual([]);
});
it('should return a copy of items (not the internal array)', () => {
service.addItem(mockItem);
const items1 = service.getItems();
items1.push({ productId: 99, name: 'Hacked', price: 0, quantity: 1 });
// Internal array should NOT be mutated
expect(service.getItems().length).toBe(1);
});
afterEach(() => {
// Each test gets a fresh service instance via TestBed
// No manual cleanup needed here
});
});
- Use
TestBed.inject(ServiceName)instead ofnew ServiceName()โ it works correctly when the service has its own dependencies. - Each
beforeEachcreates a fresh TestBed and a new service instance. Tests are isolated by default. - Testing that a function does NOT throw:
expect(() => fn()).not.toThrow() - For simple pure services, you CAN use
new CartService()directly. Only use TestBed when the service usesinject()internally.
HTTP with HttpClientTestingModule
Intercepting HTTP requests in tests, flushing mock responses, and testing error handling. HttpClientTestingModule replaces the real HTTP client, giving you full control over responses without hitting a real server.
// product.service.ts
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
export interface Product {
id: number;
name: string;
price: number;
stock: number;
}
export interface ProductsResponse {
data: Product[];
total: number;
}
@Injectable({ providedIn: 'root' })
export class ProductService {
private apiUrl = '/api/products';
constructor(private http: HttpClient) {}
getProducts(page = 1, limit = 10): Observable<ProductsResponse> {
const params = new HttpParams()
.set('page', page.toString())
.set('limit', limit.toString());
return this.http.get<ProductsResponse>(this.apiUrl, { params });
}
getProductById(id: number): Observable<Product> {
return this.http.get<Product>(`${this.apiUrl}/${id}`);
}
createProduct(product: Omit<Product, 'id'>): Observable<Product> {
return this.http.post<Product>(this.apiUrl, product);
}
updateStock(id: number, delta: number): Observable<Product> {
return this.http.patch<Product>(`${this.apiUrl}/${id}/stock`, { delta });
}
}
// product.service.spec.ts
import { TestBed } from '@angular/core/testing';
import {
HttpClientTestingModule,
HttpTestingController
} from '@angular/common/http/testing';
import { ProductService, Product, ProductsResponse } from './product.service';
describe('ProductService โ HTTP', () => {
let service: ProductService;
let httpMock: HttpTestingController;
const mockProduct: Product = { id: 1, name: 'Laptop', price: 999, stock: 5 };
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule], // replaces real HttpClient
providers: [ProductService]
});
service = TestBed.inject(ProductService);
// HttpTestingController lets us intercept and control HTTP requests
httpMock = TestBed.inject(HttpTestingController);
});
// CRITICAL: After each test, verify no unexpected requests remain
afterEach(() => {
httpMock.verify(); // throws if any request was unhandled
});
// โโ GET with query params โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should GET products with correct query params', () => {
const mockResponse: ProductsResponse = { data: [mockProduct], total: 1 };
let result: ProductsResponse | undefined;
service.getProducts(2, 5).subscribe(res => (result = res));
// Intercept the pending request
const req = httpMock.expectOne(r =>
r.url === '/api/products' &&
r.params.get('page') === '2' &&
r.params.get('limit') === '5'
);
expect(req.request.method).toBe('GET');
// Flush: provide the mock response to the observable
req.flush(mockResponse);
expect(result).toEqual(mockResponse);
});
// โโ GET by ID โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should GET a single product by ID', () => {
let product: Product | undefined;
service.getProductById(1).subscribe(p => (product = p));
const req = httpMock.expectOne('/api/products/1');
req.flush(mockProduct);
expect(product?.name).toBe('Laptop');
expect(product?.price).toBe(999);
});
// โโ POST โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should POST to create a new product', () => {
const newProduct = { name: 'Mouse', price: 25, stock: 100 };
let created: Product | undefined;
service.createProduct(newProduct).subscribe(p => (created = p));
const req = httpMock.expectOne('/api/products');
expect(req.request.method).toBe('POST');
// Verify request body was sent correctly
expect(req.request.body).toEqual(newProduct);
req.flush({ id: 99, ...newProduct });
expect(created?.id).toBe(99);
});
// โโ Error Response โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should handle 404 error from getProductById', () => {
let errorOccurred = false;
service.getProductById(999).subscribe({
next: () => fail('Expected an error, not a success'),
error: (err) => {
errorOccurred = true;
expect(err.status).toBe(404);
}
});
const req = httpMock.expectOne('/api/products/999');
// Flush with an error
req.flush('Not Found', { status: 404, statusText: 'Not Found' });
expect(errorOccurred).toBeTrue();
});
// โโ Network Error โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should handle network errors', () => {
let hadError = false;
service.getProducts().subscribe({ error: () => (hadError = true) });
const req = httpMock.expectOne(r => r.url === '/api/products');
req.error(new ProgressEvent('error')); // simulate network error
expect(hadError).toBeTrue();
});
});
- Always call
httpMock.verify()inafterEachโ it fails the test if any HTTP requests were made but not explicitly expected/flushed. - Use
httpMock.expectOne(url)for simple URL matching; use the callback formexpectOne(r => ...)to check params, headers, and method. - Call
req.flush(data)to provide mock response data. Callreq.flush(msg, {status: 4xx, statusText: '...'})to simulate HTTP errors. - Call
req.error(new ProgressEvent('error'))to simulate a network-level error (no response).
HTTP Interceptors
Testing that interceptors correctly modify outgoing requests (add headers, tokens) and handle response errors (401 redirect). Interceptors are middleware โ test them by running real HTTP calls through the full interceptor chain.
// auth.interceptor.ts
import { Injectable } from '@angular/core';
import {
HttpRequest, HttpHandler, HttpEvent,
HttpInterceptor, HttpErrorResponse, HttpResponse
} from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
import { AuthService } from './auth.service';
import { Router } from '@angular/router';
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
constructor(private auth: AuthService, private router: Router) {}
intercept(req: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
const token = this.auth.getToken();
// Clone and attach Authorization header if token exists
const authReq = token
? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
: req;
return next.handle(authReq).pipe(
tap(event => {
if (event instanceof HttpResponse) {
// Could log successful responses here
}
}),
catchError((err: HttpErrorResponse) => {
if (err.status === 401) {
this.auth.logout();
this.router.navigate(['/login']);
}
return throwError(() => err);
})
);
}
}
// auth.interceptor.spec.ts
import { TestBed } from '@angular/core/testing';
import {
HttpClientTestingModule,
HttpTestingController
} from '@angular/common/http/testing';
import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http';
import { Router } from '@angular/router';
import { AuthInterceptor } from './auth.interceptor';
import { AuthService } from './auth.service';
describe('AuthInterceptor', () => {
let httpMock: HttpTestingController;
let httpClient: HttpClient;
let mockAuthService: jasmine.SpyObj<AuthService>;
let mockRouter: jasmine.SpyObj<Router>;
beforeEach(() => {
mockAuthService = jasmine.createSpyObj('AuthService', ['getToken', 'logout']);
mockRouter = jasmine.createSpyObj('Router', ['navigate']);
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
// Register the interceptor via the multi-provider token
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
{ provide: AuthService, useValue: mockAuthService },
{ provide: Router, useValue: mockRouter }
]
});
httpClient = TestBed.inject(HttpClient);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
// โโ Token Injection โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should add Authorization header when token exists', () => {
mockAuthService.getToken.and.returnValue('my-jwt-token');
httpClient.get('/api/data').subscribe();
const req = httpMock.expectOne('/api/data');
// Verify the interceptor modified the request headers
expect(req.request.headers.get('Authorization')).toBe('Bearer my-jwt-token');
req.flush({});
});
it('should NOT add Authorization header when no token', () => {
mockAuthService.getToken.and.returnValue(null);
httpClient.get('/api/data').subscribe();
const req = httpMock.expectOne('/api/data');
expect(req.request.headers.has('Authorization')).toBeFalse();
req.flush({});
});
// โโ 401 Handling โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should call logout and navigate to /login on 401 error', () => {
mockAuthService.getToken.and.returnValue('expired-token');
httpClient.get('/api/protected').subscribe({ error: () => {} });
const req = httpMock.expectOne('/api/protected');
req.flush('Unauthorized', { status: 401, statusText: 'Unauthorized' });
// Verify side-effects triggered by the interceptor
expect(mockAuthService.logout).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).toHaveBeenCalledWith(['/login']);
});
it('should NOT call logout on non-401 errors', () => {
mockAuthService.getToken.and.returnValue('valid-token');
httpClient.get('/api/data').subscribe({ error: () => {} });
const req = httpMock.expectOne('/api/data');
req.flush('Server Error', { status: 500, statusText: 'Internal Server Error' });
expect(mockAuthService.logout).not.toHaveBeenCalled();
});
});
- Register interceptors in TestBed via
{ provide: HTTP_INTERCEPTORS, useClass: YourInterceptor, multi: true }. - Test the interceptor by making real
HttpClientcalls โ not by calling the interceptor'sintercept()method directly. - Inject
HttpClient(not the service under test) to make raw HTTP calls that pass through all interceptors. - Multiple interceptors can be stacked โ they execute in order of registration.
Pipes
Testing pure pipes directly (as plain functions) and impure pipes using a host component. Pipes are the easiest Angular unit to test โ a pure pipe is literally just a function that transforms input to output.
// currency-format.pipe.ts (Pure Pipe)
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({ name: 'currencyFormat', pure: true })
export class CurrencyFormatPipe implements PipeTransform {
transform(
value: number | null | undefined,
currency = 'USD',
locale = 'en-US'
): string {
if (value === null || value === undefined) return 'โ';
if (isNaN(value)) return 'Invalid';
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
minimumFractionDigits: 2
}).format(value);
}
}
// time-ago.pipe.ts (Impure Pipe โ needs TestBed for change detection)
import { Pipe, PipeTransform, ChangeDetectorRef, NgZone } from '@angular/core';
@Pipe({ name: 'timeAgo', pure: false })
export class TimeAgoPipe implements PipeTransform {
private timer: ReturnType<typeof setTimeout> | null = null;
constructor(private changeDetector: ChangeDetectorRef) {}
transform(value: Date | string | number): string {
const date = new Date(value);
const diff = Math.floor((Date.now() - date.getTime()) / 1000);
if (diff < 60) return 'just now';
if (diff < 3600) return `${Math.floor(diff / 60)} minutes ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)} hours ago`;
return `${Math.floor(diff / 86400)} days ago`;
}
}
// currency-format.pipe.spec.ts + time-ago.pipe.spec.ts
import { CurrencyFormatPipe } from './currency-format.pipe';
import { TimeAgoPipe } from './time-ago.pipe';
import { TestBed, ComponentFixture } from '@angular/core/testing';
import { Component, ChangeDetectorRef } from '@angular/core';
// โโ Pure Pipe โ NO TestBed needed โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
describe('CurrencyFormatPipe (Pure)', () => {
let pipe: CurrencyFormatPipe;
beforeEach(() => {
// Pure pipes have no dependencies โ instantiate directly
pipe = new CurrencyFormatPipe();
});
it('should format USD by default', () => {
const result = pipe.transform(1234.5);
expect(result).toBe('$1,234.50');
});
it('should format with a different currency', () => {
const result = pipe.transform(500, 'EUR', 'de-DE');
expect(result).toContain('500');
expect(result).toContain('โฌ');
});
// โโ Edge Cases โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should return "โ" for null', () => {
expect(pipe.transform(null)).toBe('โ');
});
it('should return "โ" for undefined', () => {
expect(pipe.transform(undefined)).toBe('โ');
});
it('should return "Invalid" for NaN', () => {
expect(pipe.transform(NaN)).toBe('Invalid');
});
it('should handle zero correctly', () => {
expect(pipe.transform(0)).toBe('$0.00');
});
it('should handle negative values', () => {
expect(pipe.transform(-50)).toBe('-$50.00');
});
});
// โโ Impure Pipe โ Use TestBed with host component โโโโโโโโโโโโโ
@Component({
template: `<span>{{ date | timeAgo }}</span>`
})
class TestHostComponent {
date = new Date();
}
describe('TimeAgoPipe (Impure)', () => {
let fixture: ComponentFixture<TestHostComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [TestHostComponent, TimeAgoPipe]
}).compileComponents();
fixture = TestBed.createComponent(TestHostComponent);
fixture.detectChanges();
});
it('should show "just now" for current time', () => {
const span = fixture.nativeElement.querySelector('span');
expect(span.textContent).toBe('just now');
});
it('should show minutes ago for a date 5 minutes in the past', () => {
const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000);
fixture.componentInstance.date = fiveMinAgo;
fixture.detectChanges();
const span = fixture.nativeElement.querySelector('span');
expect(span.textContent).toContain('minutes ago');
});
it('should transform directly without component for unit test', () => {
// Can still call transform() directly on impure pipes
const mockRef = { markForCheck: jasmine.createSpy() } as unknown as ChangeDetectorRef;
const pipe = new TimeAgoPipe(mockRef);
const yesterday = new Date(Date.now() - 25 * 3600 * 1000);
expect(pipe.transform(yesterday)).toContain('days ago');
});
});
- Pure pipes can be tested with
new PipeName()โ no TestBed needed. They are pure functions: same input โ same output. - Impure pipes often depend on
ChangeDetectorRefor services โ use a host component in TestBed. - Test pipe edge cases rigorously:
null,undefined,0, empty string, and unexpected types. - Locale-sensitive output (
Intl.NumberFormat) may vary between environments โ usetoContain()or regex instead of exact string matching.
Directives (Attribute & Structural)
Testing attribute directives for DOM style effects and structural directives for conditional rendering. Directives require a host element in the DOM โ always create a minimal host component to wrap them in tests.
// highlight.directive.ts (Attribute Directive)
import {
Directive, ElementRef, Input,
HostListener, Renderer2, OnChanges
} from '@angular/core';
@Directive({ selector: '[appHighlight]' })
export class HighlightDirective implements OnChanges {
@Input('appHighlight') highlightColor = 'yellow';
@Input() defaultColor = 'transparent';
constructor(private el: ElementRef, private renderer: Renderer2) {}
ngOnChanges(): void {
this.renderer.setStyle(this.el.nativeElement, 'backgroundColor', this.defaultColor);
}
@HostListener('mouseenter')
onMouseEnter(): void {
this.renderer.setStyle(this.el.nativeElement, 'backgroundColor', this.highlightColor);
}
@HostListener('mouseleave')
onMouseLeave(): void {
this.renderer.setStyle(this.el.nativeElement, 'backgroundColor', this.defaultColor);
}
}
// unless.directive.ts (Structural Directive)
import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';
@Directive({ selector: '[appUnless]' })
export class UnlessDirective {
private hasView = false;
constructor(
private templateRef: TemplateRef<unknown>,
private viewContainer: ViewContainerRef
) {}
@Input() set appUnless(condition: boolean) {
if (!condition && !this.hasView) {
this.viewContainer.createEmbeddedView(this.templateRef);
this.hasView = true;
} else if (condition && this.hasView) {
this.viewContainer.clear();
this.hasView = false;
}
}
}
// highlight.directive.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component } from '@angular/core';
import { By } from '@angular/platform-browser';
import { HighlightDirective } from './highlight.directive';
import { UnlessDirective } from './unless.directive';
// โโ Test Attribute Directive with host component โโโโโโโโโโโโโโโ
@Component({
template: `
<p [appHighlight]="color" [defaultColor]="'white'" data-testid="item">
Hover me
</p>
`
})
class HighlightHostComponent {
color = 'pink';
}
describe('HighlightDirective', () => {
let fixture: ComponentFixture<HighlightHostComponent>;
let el: HTMLElement;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [HighlightHostComponent, HighlightDirective]
}).compileComponents();
fixture = TestBed.createComponent(HighlightHostComponent);
fixture.detectChanges();
el = fixture.debugElement.query(By.css('[data-testid="item"]')).nativeElement;
});
it('should set default background color on init', () => {
expect(el.style.backgroundColor).toBe('white');
});
it('should change background on mouseenter', () => {
const directive = fixture.debugElement.query(By.directive(HighlightDirective));
// Trigger HostListener by dispatching event
directive.triggerEventHandler('mouseenter', null);
expect(el.style.backgroundColor).toBe('pink');
});
it('should revert background on mouseleave', () => {
const directive = fixture.debugElement.query(By.directive(HighlightDirective));
directive.triggerEventHandler('mouseenter', null);
directive.triggerEventHandler('mouseleave', null);
expect(el.style.backgroundColor).toBe('white');
});
it('should update color when @Input changes', () => {
fixture.componentInstance.color = 'lightblue';
fixture.detectChanges();
const directive = fixture.debugElement.query(By.directive(HighlightDirective));
directive.triggerEventHandler('mouseenter', null);
expect(el.style.backgroundColor).toBe('lightblue');
});
});
// โโ Test Structural Directive with host component โโโโโโโโโโโโโโ
@Component({
template: `
<div *appUnless="condition" data-testid="content">Visible</div>
`
})
class UnlessHostComponent {
condition = false;
}
describe('UnlessDirective', () => {
let fixture: ComponentFixture<UnlessHostComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [UnlessHostComponent, UnlessDirective]
}).compileComponents();
fixture = TestBed.createComponent(UnlessHostComponent);
fixture.detectChanges();
});
it('should show content when condition is false', () => {
const div = fixture.debugElement.query(By.css('[data-testid="content"]'));
expect(div).toBeTruthy();
});
it('should hide content when condition is true', () => {
fixture.componentInstance.condition = true;
fixture.detectChanges();
const div = fixture.debugElement.query(By.css('[data-testid="content"]'));
expect(div).toBeNull();
});
});
- Always test directives via a host component created in the spec file โ you need a real DOM element to apply the directive to.
- Use
By.directive(HighlightDirective)to find elements with a specific directive applied. - Use
debugElement.triggerEventHandler('mouseenter', null)to fire HostListener events โ don't rely on real browser mouse events. - For structural directives (
*ngIfstyle), check whether elements are present (toBeTruthy()) or absent (toBeNull()) in the DOM.
Guards (CanActivate, CanDeactivate)
Testing route guards that protect navigation. Guards return boolean or UrlTree โ test each outcome: allowed, redirected (UrlTree), and blocked. Use RouterTestingModule to get a real Router instance.
// auth.guard.ts โ CanActivate
import { Injectable } from '@angular/core';
import {
CanActivate, ActivatedRouteSnapshot,
RouterStateSnapshot, Router, UrlTree
} from '@angular/router';
import { Observable } from 'rxjs';
import { AuthService } from './auth.service';
@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {
constructor(private auth: AuthService, private router: Router) {}
canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): boolean | UrlTree {
if (this.auth.isLoggedIn()) {
const requiredRole = route.data['role'];
if (requiredRole && !this.auth.hasRole(requiredRole)) {
return this.router.createUrlTree(['/forbidden']);
}
return true;
}
// Store attempted URL for redirect after login
return this.router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url }
});
}
}
// unsaved-changes.guard.ts โ CanDeactivate
import { CanDeactivate } from '@angular/router';
export interface CanDeactivateComponent {
hasUnsavedChanges(): boolean;
}
@Injectable({ providedIn: 'root' })
export class UnsavedChangesGuard implements CanDeactivate<CanDeactivateComponent> {
canDeactivate(component: CanDeactivateComponent): boolean {
if (component.hasUnsavedChanges()) {
return confirm('You have unsaved changes. Leave anyway?');
}
return true;
}
}
// auth.guard.spec.ts
import { TestBed } from '@angular/core/testing';
import { Router, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { AuthGuard } from './auth.guard';
import { AuthService } from './auth.service';
import { UnsavedChangesGuard, CanDeactivateComponent } from './unsaved-changes.guard';
describe('AuthGuard', () => {
let guard: AuthGuard;
let mockAuth: jasmine.SpyObj<AuthService>;
let router: Router;
// Minimal mock snapshots โ only include what the guard actually uses
const mockRoute = { data: {} } as ActivatedRouteSnapshot;
const mockState = { url: '/dashboard' } as RouterStateSnapshot;
beforeEach(() => {
mockAuth = jasmine.createSpyObj('AuthService', ['isLoggedIn', 'hasRole']);
TestBed.configureTestingModule({
imports: [RouterTestingModule],
providers: [
AuthGuard,
{ provide: AuthService, useValue: mockAuth }
]
});
guard = TestBed.inject(AuthGuard);
router = TestBed.inject(Router);
});
// โโ Logged In (Happy Path) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should return true when user is logged in', () => {
mockAuth.isLoggedIn.and.returnValue(true);
const result = guard.canActivate(mockRoute, mockState);
expect(result).toBeTrue();
});
// โโ Role Check โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should allow access when user has required role', () => {
mockAuth.isLoggedIn.and.returnValue(true);
mockAuth.hasRole.and.returnValue(true);
const routeWithRole = { data: { role: 'admin' } } as unknown as ActivatedRouteSnapshot;
expect(guard.canActivate(routeWithRole, mockState)).toBeTrue();
});
it('should redirect to /forbidden when user lacks required role', () => {
mockAuth.isLoggedIn.and.returnValue(true);
mockAuth.hasRole.and.returnValue(false);
const routeWithRole = { data: { role: 'admin' } } as unknown as ActivatedRouteSnapshot;
const result = guard.canActivate(routeWithRole, mockState);
// Should return UrlTree, not boolean
expect(result).not.toBeTrue();
expect((result as any).toString()).toContain('/forbidden');
});
// โโ Not Logged In โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should redirect to /login with returnUrl when not logged in', () => {
mockAuth.isLoggedIn.and.returnValue(false);
const result = guard.canActivate(mockRoute, mockState);
const urlTree = result as any;
// UrlTree serialization contains /login
expect(router.serializeUrl(urlTree)).toContain('/login');
expect(router.serializeUrl(urlTree)).toContain('returnUrl');
});
});
describe('UnsavedChangesGuard', () => {
let guard: UnsavedChangesGuard;
const makeComponent = (hasChanges: boolean): CanDeactivateComponent => ({
hasUnsavedChanges: () => hasChanges
});
beforeEach(() => {
TestBed.configureTestingModule({ providers: [UnsavedChangesGuard] });
guard = TestBed.inject(UnsavedChangesGuard);
});
it('should allow deactivation when no unsaved changes', () => {
expect(guard.canDeactivate(makeComponent(false))).toBeTrue();
});
it('should confirm before allowing deactivation with unsaved changes', () => {
// Mock window.confirm to avoid real dialog
spyOn(window, 'confirm').and.returnValue(true);
expect(guard.canDeactivate(makeComponent(true))).toBeTrue();
expect(window.confirm).toHaveBeenCalled();
});
it('should block deactivation when user cancels confirm', () => {
spyOn(window, 'confirm').and.returnValue(false);
expect(guard.canDeactivate(makeComponent(true))).toBeFalse();
});
});
- Guards return
boolean | UrlTree | Observable | Promise. Test each case separately. - Cast
ActivatedRouteSnapshotandRouterStateSnapshotas needed:{ data: { role: 'admin' } } as unknown as ActivatedRouteSnapshot - Use
router.serializeUrl(urlTree)to convert UrlTree to a readable string for assertions. RouterTestingModuleprovides a real Router instance without requiring actual routes โ it handlescreateUrlTree().
Resolvers
Testing route resolvers that fetch data before a route activates. Resolvers handle data loading and error redirection โ verify both the happy path (data returned) and error paths (EMPTY + navigate).
// product-detail.resolver.ts
import { Injectable } from '@angular/core';
import {
Resolve, ActivatedRouteSnapshot, RouterStateSnapshot, Router
} from '@angular/router';
import { Observable, EMPTY } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { ProductService, Product } from './product.service';
@Injectable({ providedIn: 'root' })
export class ProductDetailResolver implements Resolve<Product> {
constructor(
private productService: ProductService,
private router: Router
) {}
resolve(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): Observable<Product> {
const id = Number(route.paramMap.get('id'));
if (!id || isNaN(id)) {
this.router.navigate(['/not-found']);
return EMPTY;
}
return this.productService.getProductById(id).pipe(
catchError(() => {
this.router.navigate(['/not-found']);
return EMPTY;
})
);
}
}
// product-detail.resolver.spec.ts
import { TestBed } from '@angular/core/testing';
import { Router, ActivatedRouteSnapshot, convertToParamMap } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { of, throwError, EMPTY } from 'rxjs';
import { ProductDetailResolver } from './product-detail.resolver';
import { ProductService, Product } from './product.service';
describe('ProductDetailResolver', () => {
let resolver: ProductDetailResolver;
let mockProductService: jasmine.SpyObj<ProductService>;
let router: Router;
const mockProduct: Product = { id: 42, name: 'Laptop', price: 999, stock: 5 };
// Helper: create ActivatedRouteSnapshot with paramMap
function makeRoute(id: string): ActivatedRouteSnapshot {
return {
paramMap: convertToParamMap({ id })
} as unknown as ActivatedRouteSnapshot;
}
beforeEach(() => {
mockProductService = jasmine.createSpyObj('ProductService', ['getProductById']);
TestBed.configureTestingModule({
imports: [RouterTestingModule],
providers: [
ProductDetailResolver,
{ provide: ProductService, useValue: mockProductService }
]
});
resolver = TestBed.inject(ProductDetailResolver);
router = TestBed.inject(Router);
spyOn(router, 'navigate');
});
// โโ Happy Path โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should resolve and return product for valid ID', (done) => {
mockProductService.getProductById.and.returnValue(of(mockProduct));
resolver.resolve(makeRoute('42'), {} as any).subscribe(product => {
expect(product).toEqual(mockProduct);
expect(mockProductService.getProductById).toHaveBeenCalledWith(42);
done();
});
});
// โโ Error Case โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should navigate to /not-found and return EMPTY on HTTP error', (done) => {
mockProductService.getProductById.and.returnValue(
throwError(() => new Error('Not found'))
);
resolver.resolve(makeRoute('99'), {} as any).subscribe({
complete: () => {
// EMPTY completes immediately without emitting
expect(router.navigate).toHaveBeenCalledWith(['/not-found']);
done();
}
});
});
// โโ Invalid ID โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should navigate to /not-found for invalid (non-numeric) ID', (done) => {
resolver.resolve(makeRoute('abc'), {} as any).subscribe({
complete: () => {
expect(router.navigate).toHaveBeenCalledWith(['/not-found']);
expect(mockProductService.getProductById).not.toHaveBeenCalled();
done();
}
});
});
it('should navigate to /not-found for missing ID (empty string)', (done) => {
resolver.resolve(makeRoute(''), {} as any).subscribe({
complete: () => {
expect(router.navigate).toHaveBeenCalledWith(['/not-found']);
done();
}
});
});
});
- Use
convertToParamMap({ id: '42' })to create a properParamMapforActivatedRouteSnapshot.paramMap. - When a resolver returns
EMPTY, subscribe with acompletecallback to know when it finishes. - Always cast the second argument (
RouterStateSnapshot) as{} as anyin tests โ resolvers rarely use it. - Test that invalid IDs prevent the HTTP call entirely โ the service should not be called.
Routing
Testing routerLink hrefs, programmatic navigation, routerLinkActive, and router-outlet rendering. RouterTestingModule gives you a testable Router without a real browser history.
// app-routing.module.ts (excerpt)
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { HomeComponent } from './home/home.component';
import { ProductListComponent } from './products/product-list.component';
import { ProductDetailComponent } from './products/product-detail.component';
import { AuthGuard } from './guards/auth.guard';
export const routes: Routes = [
{ path: '', component: HomeComponent },
{ path: 'products', component: ProductListComponent },
{ path: 'products/:id', component: ProductDetailComponent, canActivate: [AuthGuard] },
{ path: '**', redirectTo: '' }
];
// nav.component.ts โ component with routerLink directives
import { Component } from '@angular/core';
import { Router } from '@angular/router';
@Component({
selector: 'app-nav',
template: `
<nav>
<a routerLink="/" routerLinkActive="active" data-testid="home-link">Home</a>
<a routerLink="/products" routerLinkActive="active" data-testid="products-link">Products</a>
<button (click)="goToProduct(42)" data-testid="nav-btn">Product 42</button>
</nav>
<router-outlet></router-outlet>
`
})
export class NavComponent {
constructor(private router: Router) {}
goToProduct(id: number): void {
this.router.navigate(['/products', id]);
}
}
// routing.spec.ts
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { Router } from '@angular/router';
import { Location } from '@angular/common';
import { By } from '@angular/platform-browser';
import { NavComponent } from './nav.component';
import { routes } from './app-routing.module';
import { HomeComponent } from './home/home.component';
import { ProductListComponent } from './products/product-list.component';
import { Component } from '@angular/core';
// Stub components for testing navigation (avoids heavy deps)
@Component({ template: '' }) class HomeStub {}
@Component({ template: '' }) class ProductListStub {}
@Component({ template: '' }) class ProductDetailStub {}
describe('NavComponent โ Routing', () => {
let fixture: ComponentFixture<NavComponent>;
let router: Router;
let location: Location;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [NavComponent, HomeStub, ProductListStub, ProductDetailStub],
imports: [
RouterTestingModule.withRoutes([
{ path: '', component: HomeStub },
{ path: 'products', component: ProductListStub },
{ path: 'products/:id', component: ProductDetailStub }
])
]
}).compileComponents();
router = TestBed.inject(Router);
location = TestBed.inject(Location);
fixture = TestBed.createComponent(NavComponent);
fixture.detectChanges();
});
// โโ RouterLink href โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should have correct href on routerLink anchors', () => {
const homeLink = fixture.debugElement.query(By.css('[data-testid="home-link"]'));
const productsLink = fixture.debugElement.query(By.css('[data-testid="products-link"]'));
expect(homeLink.nativeElement.getAttribute('href')).toBe('/');
expect(productsLink.nativeElement.getAttribute('href')).toBe('/products');
});
// โโ Programmatic navigation โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should navigate to /products/42 when goToProduct(42) called', fakeAsync(() => {
fixture.componentInstance.goToProduct(42);
tick(); // flush async router operations
expect(location.path()).toBe('/products/42');
}));
// โโ Navigate via router directly โโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should navigate to /products via router.navigate', fakeAsync(() => {
router.navigate(['/products']);
tick();
expect(location.path()).toBe('/products');
}));
// โโ routerLinkActive โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should apply active class to current route link', fakeAsync(() => {
router.navigate(['/products']);
tick();
fixture.detectChanges();
const productsLink = fixture.debugElement.query(By.css('[data-testid="products-link"]'));
expect(productsLink.nativeElement.classList).toContain('active');
}));
// โโ Button click navigation โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should navigate on button click', fakeAsync(() => {
const btn = fixture.debugElement.query(By.css('[data-testid="nav-btn"]'));
btn.nativeElement.click();
tick();
expect(location.path()).toBe('/products/42');
}));
});
- Use
RouterTestingModule.withRoutes([...])to register test routes. Use stub components to avoid pulling in real component dependencies. - Wrap navigation assertions in
fakeAsyncand calltick()โ navigation is async. - Use
Location.path()(from@angular/common) to check the current URL after navigation. - Test
routerLinkActiveby navigating first, then callingfixture.detectChanges()to apply the CSS class.
RxJS ObservablesRxJS
Testing RxJS streams with three patterns: done() callback, fakeAsync+tick, and marble testing. Each pattern suits different scenarios โ done() for simple cases, fakeAsync for time-based operators like debounce.
// notification.service.ts
import { Injectable } from '@angular/core';
import {
Subject, BehaviorSubject, Observable, timer
} from 'rxjs';
import { debounceTime, distinctUntilChanged, switchMap, map } from 'rxjs/operators';
export interface Notification {
id: number;
message: string;
type: 'success' | 'error' | 'info';
}
@Injectable({ providedIn: 'root' })
export class NotificationService {
private notificationsSubject = new BehaviorSubject<Notification[]>([]);
notifications$ = this.notificationsSubject.asObservable();
private searchSubject = new Subject<string>();
searchResults$ = this.searchSubject.pipe(
debounceTime(300),
distinctUntilChanged()
);
add(notification: Omit<Notification, 'id'>): void {
const current = this.notificationsSubject.getValue();
const next = [...current, { ...notification, id: Date.now() }];
this.notificationsSubject.next(next);
}
dismiss(id: number): void {
const filtered = this.notificationsSubject.getValue()
.filter(n => n.id !== id);
this.notificationsSubject.next(filtered);
}
search(term: string): void {
this.searchSubject.next(term);
}
getCount(): Observable<number> {
return this.notifications$.pipe(map(n => n.length));
}
}
// notification.service.spec.ts
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { NotificationService } from './notification.service';
// marble testing: jasmine-marbles (install: npm i jasmine-marbles -D)
// import { cold, hot } from 'jasmine-marbles';
describe('NotificationService โ RxJS', () => {
let service: NotificationService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(NotificationService);
});
// โโ Method 1: done() callback (simple async) โโโโโโโโโโโโโโ
it('should emit empty array initially (done callback)', (done) => {
service.notifications$.subscribe(notifications => {
expect(notifications).toEqual([]);
done(); // tell Jasmine the async test is done
});
});
// โโ Method 2: fakeAsync + tick (most common) โโโโโโโโโโโโโโ
it('should emit updated notifications after add() (fakeAsync)', fakeAsync(() => {
const emitted: any[] = [];
service.notifications$.subscribe(n => emitted.push(n));
service.add({ message: 'Success!', type: 'success' });
tick(); // flush microtasks/timers
expect(emitted.length).toBe(2); // initial + after add
expect(emitted[1][0].message).toBe('Success!');
}));
it('should debounce search emissions', fakeAsync(() => {
const results: string[] = [];
service.searchResults$.subscribe(v => results.push(v));
service.search('a');
service.search('an');
service.search('ang'); // only last one should emit (after 300ms)
tick(299); // not yet
expect(results.length).toBe(0);
tick(1); // total 300ms โ debounce fires
expect(results.length).toBe(1);
expect(results[0]).toBe('ang');
}));
it('should filter duplicate consecutive searches (distinctUntilChanged)', fakeAsync(() => {
const results: string[] = [];
service.searchResults$.subscribe(v => results.push(v));
service.search('angular');
tick(300);
service.search('angular'); // duplicate โ should not emit
tick(300);
service.search('react');
tick(300);
expect(results).toEqual(['angular', 'react']); // 2, not 3
}));
// โโ BehaviorSubject โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should emit current value immediately on subscribe (BehaviorSubject)', () => {
service.add({ message: 'Alert', type: 'info' });
const collected: number[] = [];
service.getCount().subscribe(c => collected.push(c));
// New subscriber gets the CURRENT value immediately
expect(collected[0]).toBe(1);
});
// โโ Marble Testing Concept (explained) โโโโโโโโโโโโโโโโโโโ
// Marble syntax: -a--b--c- (- = 10ms frame, a/b/c = emissions, | = complete, # = error)
// With jasmine-marbles:
// const source$ = cold('-a-b|', { a: 'hello', b: 'world' });
// const expected = cold('-a-b|', { a: 'HELLO', b: 'WORLD' });
// expect(source$.pipe(map(s => s.toUpperCase()))).toBeObservable(expected);
it('should remove notification on dismiss()', fakeAsync(() => {
service.add({ message: 'Test', type: 'info' });
tick();
const id = service['notificationsSubject'].getValue()[0].id;
service.dismiss(id);
tick();
const current = service['notificationsSubject'].getValue();
expect(current.length).toBe(0);
}));
});
done()โ Use for one-shot async operations. Jasmine waits untildone()is called (timeout: 5s default).fakeAsync + tick()โ Best for debounce, throttle, setTimeout, Promises.tick(ms)advances virtual time.- Marble testing (jasmine-marbles) โ Best for complex operator pipelines. Requires
npm i jasmine-marbles -D. BehaviorSubjectemits the current value to new subscribers immediately โ test this by subscribing after emitting.- Accessing private properties in tests:
service['privateProp']โ acceptable in Jasmine, thoughanycast is cleaner.
fakeAsync, tick, flushMicrotasks
Controlling asynchronous code in tests by replacing real timers with a virtual clock. fakeAsync is essential for testing debounce, setTimeout, Promise chains, and any time-sensitive logic synchronously.
// search.component.ts โ demonstrates fakeAsync scenarios
import { Component, OnInit, OnDestroy } from '@angular/core';
import { FormControl } from '@angular/forms';
import { Subject } from 'rxjs';
import { debounceTime, distinctUntilChanged, takeUntil } from 'rxjs/operators';
@Component({
selector: 'app-search',
template: ``
})
export class SearchComponent implements OnInit, OnDestroy {
searchControl = new FormControl('');
results: string[] = [];
isLoading = false;
private destroy$ = new Subject();
ngOnInit(): void {
this.searchControl.valueChanges.pipe(
debounceTime(400),
distinctUntilChanged(),
takeUntil(this.destroy$)
).subscribe(term => {
this.isLoading = true;
this.simulateSearch(term ?? '');
});
}
simulateSearch(term: string): void {
// Simulates async search delay
setTimeout(() => {
this.results = term ? [`Result for: ${term}`] : [];
this.isLoading = false;
}, 200);
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
// fakeAsync.spec.ts
import { ComponentFixture, TestBed, fakeAsync, tick, flushMicrotasks } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { SearchComponent } from './search.component';
describe('fakeAsync, tick, flushMicrotasks', () => {
let component: SearchComponent;
let fixture: ComponentFixture;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [SearchComponent],
imports: [ReactiveFormsModule]
}).compileComponents();
fixture = TestBed.createComponent(SearchComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
// โโ setTimeout scenario โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should set results after simulateSearch (setTimeout 200ms)', fakeAsync(() => {
component.simulateSearch('angular');
expect(component.results).toEqual([]); // not yet โ setTimeout pending
tick(200); // advance virtual clock by 200ms
expect(component.results).toEqual(['Result for: angular']);
expect(component.isLoading).toBeFalse();
}));
// โโ debounceTime scenario โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should only trigger search after debounce delay', fakeAsync(() => {
component.searchControl.setValue('a');
component.searchControl.setValue('an');
component.searchControl.setValue('angular'); // rapid typing
tick(399); // debounceTime is 400ms โ not fired yet
expect(component.isLoading).toBeFalse();
tick(1); // now 400ms total โ debounce fires
expect(component.isLoading).toBeTrue(); // search started
tick(200); // wait for setTimeout in simulateSearch
expect(component.isLoading).toBeFalse();
expect(component.results[0]).toContain('angular');
}));
// โโ Promise / flushMicrotasks โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should resolve Promise with flushMicrotasks', fakeAsync(() => {
let value = '';
Promise.resolve('hello').then(v => (value = v));
expect(value).toBe(''); // promise not resolved yet
flushMicrotasks(); // flush the microtask queue (Promise.then)
expect(value).toBe('hello');
}));
// โโ tick(0) for async/await โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should handle async/await with tick(0)', fakeAsync(() => {
let resolved = false;
async function doAsync() {
await Promise.resolve();
resolved = true;
}
doAsync();
expect(resolved).toBeFalse();
flushMicrotasks(); // resolves the await
expect(resolved).toBeTrue();
}));
// โโ Combined: multiple timers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should handle cascaded setTimeout calls', fakeAsync(() => {
const order: number[] = [];
setTimeout(() => order.push(1), 100);
setTimeout(() => order.push(2), 300);
setTimeout(() => order.push(3), 150);
tick(100); expect(order).toEqual([1]);
tick(50); expect(order).toEqual([1, 3]); // 150ms total
tick(150); expect(order).toEqual([1, 3, 2]); // 300ms total
}));
});
fakeAsyncwraps the test to give you synchronous control over async operations.tick(ms)advances the virtual clock โ use it forsetTimeout,setInterval, and RxJS time-based operators likedebounceTime/delay.flushMicrotasks()processes the microtask queue (Promises,async/await) without advancing time.flush()(no argument) drains all pending macrotasks โ useful when you don't care about exact timing.- โ ๏ธ If you forget to drain all timers at the end of a
fakeAsynctest, Jasmine will throw "X timer(s) still in the queue".
Signals (Angular 16-17)NEW in A16
Testing Angular Signals โ the new reactive primitive replacing RxJS for simple state. Signals are synchronous and predictable, making them the easiest Angular feature to test.
// counter-signal.component.ts (Angular 16+)
import { Component, signal, computed, effect, OnInit } from '@angular/core';
@Component({
selector: 'app-signal-counter',
template: `
Count: {{ count() }}
Double: {{ doubleCount() }}
{{ statusMessage() }}
`
})
export class SignalCounterComponent {
// Writable signal
count = signal(0);
// Computed signal โ auto-updates when count changes
doubleCount = computed(() => this.count() * 2);
// Computed with conditional logic
statusMessage = computed(() => {
const c = this.count();
if (c === 0) return 'Start counting!';
if (c < 0) return 'Going negative!';
return `You have counted to ${c}`;
});
history: number[] = [];
constructor() {
// effect() runs whenever its signal dependencies change
effect(() => {
this.history.push(this.count());
});
}
increment(): void { this.count.update(c => c + 1); }
decrement(): void { this.count.update(c => c - 1); }
reset(): void { this.count.set(0); }
}
// counter-signal.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { SignalCounterComponent } from './counter-signal.component';
describe('SignalCounterComponent โ Angular Signals', () => {
let component: SignalCounterComponent;
let fixture: ComponentFixture;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [SignalCounterComponent]
}).compileComponents();
fixture = TestBed.createComponent(SignalCounterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
// โโ Signal Read / Write โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should initialize count signal to 0', () => {
// Signals are read by calling them as functions
expect(component.count()).toBe(0);
});
it('should update count signal with set()', () => {
component.count.set(5);
expect(component.count()).toBe(5);
});
it('should update count with update() (functional)', () => {
component.count.set(3);
component.count.update(c => c + 2);
expect(component.count()).toBe(5);
});
// โโ computed() โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should compute doubleCount reactively', () => {
component.count.set(4);
// computed() re-evaluates automatically
expect(component.doubleCount()).toBe(8);
});
it('should update statusMessage computed signal', () => {
component.count.set(0);
expect(component.statusMessage()).toBe('Start counting!');
component.count.set(-1);
expect(component.statusMessage()).toBe('Going negative!');
component.count.set(3);
expect(component.statusMessage()).toContain('3');
});
// โโ DOM reflection โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should reflect count signal in template', () => {
component.count.set(7);
fixture.detectChanges(); // sync signal โ template
const el = fixture.debugElement.query(By.css('[data-testid="count"]'));
expect(el.nativeElement.textContent).toContain('7');
});
it('should reflect doubleCount in template', () => {
component.count.set(3);
fixture.detectChanges();
const el = fixture.debugElement.query(By.css('[data-testid="double"]'));
expect(el.nativeElement.textContent).toContain('6');
});
// โโ effect() โ tested via side effects โโโโโโโโโโโโโโโโโโโ
it('should track count history via effect()', () => {
// effect() ran on init (count=0), then runs on each change
component.count.set(1);
fixture.detectChanges();
component.count.set(2);
fixture.detectChanges();
// history should contain 0 (init), 1, 2
expect(component.history).toContain(0);
expect(component.history).toContain(1);
expect(component.history).toContain(2);
});
// โโ Increment / Decrement via template โโโโโโโโโโโโโโโโโโโโ
it('should increment via button click', () => {
fixture.debugElement.query(By.css('[data-testid="inc-btn"]')).nativeElement.click();
fixture.detectChanges();
expect(component.count()).toBe(1);
});
it('should reset to 0 via reset button', () => {
component.count.set(10);
fixture.debugElement.query(By.css('[data-testid="reset-btn"]')).nativeElement.click();
fixture.detectChanges();
expect(component.count()).toBe(0);
});
});
- Read signals by calling them as functions:
mySignal()โ notmySignal.value. signal.set(value)replaces the value;signal.update(fn)updates based on the current value.computed()signals are lazy and cached โ they only recompute when their dependencies change.- Test
effect()indirectly via its side effects (logs, history arrays, external calls) โ effects run during change detection in a TestBed environment. - โ ๏ธ Signals in Angular 16 require
fixture.detectChanges()to sync to the DOM, just like regular bindings.
State Management (NgRx)NgRx
Testing NgRx reducers, selectors, and effects. Reducers and selectors are pure functions โ the easiest tests to write. Effects need provideMockActions to simulate the action stream.
// counter.reducer.ts + counter.selectors.ts + counter.effects.ts
import { createReducer, on, createAction, props,
createSelector, createFeatureSelector, createEffect,
ofType, Actions } from '@ngrx/store';
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { switchMap, map, catchError } from 'rxjs/operators';
import { ProductService } from './product.service';
// --- Actions ---
export const increment = createAction('[Counter] Increment');
export const decrement = createAction('[Counter] Decrement');
export const reset = createAction('[Counter] Reset');
export const loadProducts = createAction('[Products] Load');
export const loadProductsSuccess = createAction('[Products] Load Success', props<{ products: any[] }>());
export const loadProductsFailure = createAction('[Products] Load Failure', props<{ error: string }>());
// --- State ---
export interface CounterState { count: number; }
export interface ProductsState { items: any[]; loading: boolean; error: string | null; }
const initialCounterState: CounterState = { count: 0 };
const initialProductsState: ProductsState = { items: [], loading: false, error: null };
// --- Reducer ---
export const counterReducer = createReducer(
initialCounterState,
on(increment, state => ({ ...state, count: state.count + 1 })),
on(decrement, state => ({ ...state, count: state.count - 1 })),
on(reset, () => initialCounterState)
);
export const productsReducer = createReducer(
initialProductsState,
on(loadProducts, state => ({ ...state, loading: true, error: null })),
on(loadProductsSuccess, (state, { products }) => ({ ...state, items: products, loading: false })),
on(loadProductsFailure, (state, { error }) => ({ ...state, error, loading: false }))
);
// --- Selectors ---
export const selectCounterFeature = createFeatureSelector('counter');
export const selectCount = createSelector(selectCounterFeature, s => s.count);
export const selectDouble = createSelector(selectCount, c => c * 2);
// --- Effects ---
@Injectable()
export class ProductsEffects {
loadProducts$ = createEffect(() => this.actions$.pipe(
ofType(loadProducts),
switchMap(() => this.productService.getProducts().pipe(
map(res => loadProductsSuccess({ products: res.data })),
catchError(err => of(loadProductsFailure({ error: err.message })))
))
));
constructor(private actions$: Actions, private productService: ProductService) {}
}
// ngrx.spec.ts
import { TestBed } from '@angular/core/testing';
import { provideMockActions } from '@ngrx/effects/testing';
import { of, throwError, ReplaySubject } from 'rxjs';
import {
counterReducer, productsReducer,
increment, decrement, reset,
loadProducts, loadProductsSuccess, loadProductsFailure,
selectCount, selectDouble,
ProductsEffects, CounterState
} from './store';
import { MemoizedSelector } from '@ngrx/store';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { ProductService } from './product.service';
// โโ Reducer Tests (pure function โ no TestBed needed) โโโโโโโโโ
describe('counterReducer', () => {
const initial: CounterState = { count: 0 };
it('should return initial state for unknown action', () => {
const state = counterReducer(undefined, { type: '@@INIT' });
expect(state).toEqual(initial);
});
it('should increment count', () => {
const state = counterReducer(initial, increment());
expect(state.count).toBe(1);
});
it('should decrement count', () => {
const state = counterReducer({ count: 5 }, decrement());
expect(state.count).toBe(4);
});
it('should reset to initial state', () => {
const state = counterReducer({ count: 99 }, reset());
expect(state.count).toBe(0);
});
});
// โโ Selector Tests โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
describe('Counter Selectors', () => {
it('should select count from state', () => {
const state = { counter: { count: 7 } };
expect(selectCount(state as any)).toBe(7);
});
it('should compute double via memoized selector', () => {
const state = { counter: { count: 4 } };
expect(selectDouble(state as any)).toBe(8);
});
it('should memoize: same result for same input', () => {
const state = { counter: { count: 3 } };
const r1 = selectDouble(state as any);
const r2 = selectDouble(state as any);
expect(r1).toBe(r2); // same reference
});
});
// โโ Effects Tests โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
describe('ProductsEffects', () => {
let effects: ProductsEffects;
let actions$: ReplaySubject;
let mockProductService: jasmine.SpyObj;
beforeEach(() => {
actions$ = new ReplaySubject(1);
mockProductService = jasmine.createSpyObj('ProductService', ['getProducts']);
TestBed.configureTestingModule({
providers: [
ProductsEffects,
provideMockActions(() => actions$),
{ provide: ProductService, useValue: mockProductService }
]
});
effects = TestBed.inject(ProductsEffects);
});
it('should dispatch loadProductsSuccess on success', (done) => {
const products = [{ id: 1, name: 'Book', price: 10, stock: 5 }];
mockProductService.getProducts.and.returnValue(of({ data: products, total: 1 }));
actions$.next(loadProducts());
effects.loadProducts$.subscribe(action => {
expect(action).toEqual(loadProductsSuccess({ products }));
done();
});
});
it('should dispatch loadProductsFailure on error', (done) => {
mockProductService.getProducts.and.returnValue(throwError(() => new Error('Network fail')));
actions$.next(loadProducts());
effects.loadProducts$.subscribe(action => {
expect(action).toEqual(loadProductsFailure({ error: 'Network fail' }));
done();
});
});
});
- Reducers are pure functions โ test them with
new ReducerFn(state, action), no TestBed needed. - Selectors are also pure โ pass a plain state object and call the selector directly.
- Effects need
provideMockActionsfrom@ngrx/effects/testingand aReplaySubjectto dispatch test actions. - Use
MockStorefrom@ngrx/store/testingwhen testing components that use the store. - Test effects for both the happy path (
Successaction) and error path (Failureaction).
Error Handling in Tests
Testing that functions throw expected errors and components display error states. Angular apps fail in predictable ways โ test those failure modes explicitly so they stay predictable.
// form-validator.service.ts
import { Injectable } from '@angular/core';
import { AbstractControl, ValidationErrors } from '@angular/forms';
export class InsufficientFundsError extends Error {
constructor(public available: number, public required: number) {
super(`Insufficient funds: need ${required}, have ${available}`);
this.name = 'InsufficientFundsError';
}
}
@Injectable({ providedIn: 'root' })
export class PaymentService {
private balance = 100;
processPayment(amount: number): { success: boolean; transactionId: string } {
if (amount <= 0) throw new Error('Amount must be positive');
if (amount > this.balance) {
throw new InsufficientFundsError(this.balance, amount);
}
this.balance -= amount;
return { success: true, transactionId: `TXN-${Date.now()}` };
}
static strongPasswordValidator(control: AbstractControl): ValidationErrors | null {
const val: string = control.value || '';
if (!val) return null;
const errors: ValidationErrors = {};
if (val.length < 8) errors['minLength'] = true;
if (!/[A-Z]/.test(val)) errors['noUppercase'] = true;
if (!/[0-9]/.test(val)) errors['noNumber'] = true;
if (!/[^a-zA-Z0-9]/.test(val)) errors['noSpecial'] = true;
return Object.keys(errors).length ? errors : null;
}
}
// error-handling.spec.ts
import { TestBed, ComponentFixture } from '@angular/core/testing';
import { Component } from '@angular/core';
import { FormControl } from '@angular/forms';
import { By } from '@angular/platform-browser';
import { CommonModule } from '@angular/common';
import { PaymentService, InsufficientFundsError } from './payment.service';
// โโ Service Error Tests โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
describe('PaymentService โ Error Handling', () => {
let service: PaymentService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(PaymentService);
});
it('should return success for valid payment', () => {
const result = service.processPayment(50);
expect(result.success).toBeTrue();
expect(result.transactionId).toMatch(/^TXN-/);
});
// โโ toThrow matchers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should throw for zero amount', () => {
expect(() => service.processPayment(0)).toThrow();
});
it('should throw specific Error message for zero amount', () => {
expect(() => service.processPayment(0))
.toThrowError('Amount must be positive');
});
it('should throw InsufficientFundsError for amount exceeding balance', () => {
expect(() => service.processPayment(200))
.toThrowError(InsufficientFundsError);
});
it('should throw with correct funds details in custom error', () => {
try {
service.processPayment(200);
fail('Expected InsufficientFundsError to be thrown');
} catch (e) {
expect(e).toBeInstanceOf(InsufficientFundsError);
const err = e as InsufficientFundsError;
expect(err.available).toBe(100);
expect(err.required).toBe(200);
}
});
// โโ Validator Tests โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should return null for strong password', () => {
const ctrl = new FormControl('Secure@99');
expect(PaymentService.strongPasswordValidator(ctrl)).toBeNull();
});
it('should return minLength error for short password', () => {
const ctrl = new FormControl('Ab1!');
const errors = PaymentService.strongPasswordValidator(ctrl);
expect(errors?.['minLength']).toBeTrue();
});
it('should return all errors for weak password', () => {
const ctrl = new FormControl('password'); // all lowercase, no number, no special
const errors = PaymentService.strongPasswordValidator(ctrl);
expect(errors?.['noUppercase']).toBeTrue();
expect(errors?.['noNumber']).toBeTrue();
expect(errors?.['noSpecial']).toBeTrue();
});
it('should return null for empty/null value (required handled separately)', () => {
const ctrl = new FormControl('');
expect(PaymentService.strongPasswordValidator(ctrl)).toBeNull();
});
});
// โโ Component Error State Tests โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@Component({
template: `
{{ errorMsg }}
`
})
class PaymentFormStub {
errorMsg = '';
constructor(private payService: PaymentService) {}
pay(): void {
try { this.payService.processPayment(200); }
catch (e: any) { this.errorMsg = e.message; }
}
}
describe('Component Error State', () => {
let fixture: ComponentFixture;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [PaymentFormStub],
imports: [CommonModule],
providers: [PaymentService]
}).compileComponents();
fixture = TestBed.createComponent(PaymentFormStub);
fixture.detectChanges();
});
it('should show error banner when payment fails', () => {
fixture.debugElement.query(By.css('[data-testid="pay-btn"]')).nativeElement.click();
fixture.detectChanges();
const banner = fixture.debugElement.query(By.css('[data-testid="error-banner"]'));
expect(banner).toBeTruthy();
expect(banner.nativeElement.textContent).toContain('Insufficient funds');
});
});
expect(() => fn()).toThrow()โ checks that any error is thrown..toThrowError('message')โ checks the error message string..toThrowError(ErrorClass)โ checks the error type/class.- For catching typed errors, use a
try/catchwithexpect(e).toBeInstanceOf(MyError). - Use
fail('message')to explicitly fail a test if an expected code path was not reached.
Spies & Mocks
Mastering Jasmine spies: createSpyObj, spyOn, returnValue, callFake, callThrough, and spyOnProperty. Spies let you isolate the unit under test by controlling all its dependencies.
// analytics.service.ts + logger.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class LoggerService {
log(message: string): void { console.log(`[LOG] ${message}`); }
error(message: string): void { console.error(`[ERROR] ${message}`); }
warn(message: string): void { console.warn(`[WARN] ${message}`); }
}
export interface AnalyticsEvent {
event: string;
properties: Record;
userId?: string;
}
@Injectable({ providedIn: 'root' })
export class AnalyticsService {
private userId: string | null = null;
get currentUserId(): string | null { return this.userId; }
constructor(private logger: LoggerService, private http: HttpClient) {}
identify(userId: string): void {
this.userId = userId;
this.logger.log(`User identified: ${userId}`);
}
track(event: string, properties = {}): Observable {
const payload: AnalyticsEvent = {
event, properties,
userId: this.userId ?? undefined
};
this.logger.log(`Tracking: ${event}`);
return this.http.post('/api/analytics', payload);
}
reset(): void {
this.userId = null;
this.logger.log('Analytics reset');
}
}
// spies.spec.ts
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { AnalyticsService } from './analytics.service';
import { LoggerService } from './logger.service';
describe('Spies & Mocks โ AnalyticsService', () => {
let service: AnalyticsService;
let mockLogger: jasmine.SpyObj;
let httpMock: HttpTestingController;
beforeEach(() => {
// createSpyObj: creates a mock object with spied methods
// All methods return undefined by default unless configured
mockLogger = jasmine.createSpyObj('LoggerService', ['log', 'error', 'warn']);
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
AnalyticsService,
{ provide: LoggerService, useValue: mockLogger }
]
});
service = TestBed.inject(AnalyticsService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
// โโ toHaveBeenCalled matchers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should call logger.log once when identify() is called', () => {
service.identify('user-42');
expect(mockLogger.log).toHaveBeenCalled();
expect(mockLogger.log).toHaveBeenCalledTimes(1);
expect(mockLogger.log).toHaveBeenCalledWith('User identified: user-42');
});
// โโ returnValue โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should use spyOn with returnValue on existing object', () => {
const logger = TestBed.inject(LoggerService);
const spy = spyOn(logger, 'log').and.returnValue(undefined);
// Now logger.log() does nothing and returns undefined
logger.log('test');
expect(spy).toHaveBeenCalledWith('test');
});
// โโ callFake โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should use callFake to replace implementation', () => {
const captured: string[] = [];
mockLogger.log.and.callFake((msg: string) => captured.push(msg));
service.identify('user-1');
expect(captured).toContain('User identified: user-1');
});
// โโ callThrough โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should use callThrough to invoke the real implementation', () => {
// callThrough: spy records the call but delegates to real implementation
spyOn(console, 'log').and.callThrough();
const realLogger = new LoggerService();
spyOn(realLogger, 'log').and.callThrough();
realLogger.log('real call');
expect(realLogger.log).toHaveBeenCalledWith('real call');
});
// โโ spyOnProperty โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should use spyOnProperty for getter', () => {
// Spy on a getter property
spyOnProperty(service, 'currentUserId', 'get').and.returnValue('mocked-id');
expect(service.currentUserId).toBe('mocked-id');
});
// โโ Verifying call arguments โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should pass correct payload to HTTP when track() called', () => {
service.identify('user-5');
service.track('button_click', { button: 'submit' }).subscribe();
const req = httpMock.expectOne('/api/analytics');
expect(req.request.body).toEqual({
event: 'button_click',
properties: { button: 'submit' },
userId: 'user-5'
});
req.flush(null);
});
// โโ not.toHaveBeenCalled โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should not call logger.warn unless explicitly triggered', () => {
service.identify('user-1');
expect(mockLogger.warn).not.toHaveBeenCalled();
});
// โโ Spy call count after multiple calls โโโโโโโโโโโโโโโโโโโ
it('should log twice: once for identify, once for reset', () => {
service.identify('user-3');
service.reset();
expect(mockLogger.log).toHaveBeenCalledTimes(2);
});
});
jasmine.createSpyObj('Name', ['method1','method2'])โ creates a full mock object. All listed methods become spies.spyOn(obj, 'method')โ replaces an existing method on a real object with a spy..and.returnValue(x)โ makes the spy return a fixed value..and.callFake(fn)โ replaces the implementation with a custom function..and.callThrough()โ spy records the call and delegates to the real implementation.spyOnProperty(obj, 'prop', 'get')โ spies on a getter. Use'set'for setters.
Component Interaction (Parent-Child)
Testing @Input/@Output across parent-child component boundaries and accessing child components via @ViewChild. Test the child in isolation first, then verify the parent-child data flow as an integration test.
// parent.component.ts + child.component.ts
import { Component, Input, Output, EventEmitter, ViewChild } from '@angular/core';
// Child Component
@Component({
selector: 'app-product-card',
template: `
{{ product.name }}
${{ product.price }}
`
})
export class ProductCardComponent {
@Input() product!: { id: number; name: string; price: number };
@Input() showFavorite = false;
@Output() cartAdd = new EventEmitter(); // emits product.id
@Output() favoriteToggle = new EventEmitter();
addToCart(): void { this.cartAdd.emit(this.product.id); }
favorite(): void { this.favoriteToggle.emit(this.product.id); }
}
// Parent Component
@Component({
selector: 'app-product-list',
template: `
{{ title }}
Cart: {{ cartCount }}
`
})
export class ProductListComponent {
@ViewChild(ProductCardComponent) firstCard!: ProductCardComponent;
title = 'Products';
cartCount = 0;
favoriteIds: number[] = [];
products = [
{ id: 1, name: 'Laptop', price: 999 },
{ id: 2, name: 'Phone', price: 599 }
];
onCartAdd(productId: number): void { this.cartCount++; }
onFavorite(productId: number): void { this.favoriteIds.push(productId); }
}
// parent-child.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { CommonModule } from '@angular/common';
import { ProductListComponent, ProductCardComponent } from './product.components';
// โโ Test Child Component in Isolation โโโโโโโโโโโโโโโโโโโโโโโโโ
describe('ProductCardComponent (Child)', () => {
let fixture: ComponentFixture;
let component: ProductCardComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ProductCardComponent],
imports: [CommonModule]
}).compileComponents();
fixture = TestBed.createComponent(ProductCardComponent);
component = fixture.componentInstance;
// Set required @Input
component.product = { id: 1, name: 'Laptop', price: 999 };
fixture.detectChanges();
});
it('should render product name and price', () => {
expect(fixture.nativeElement.querySelector('[data-testid="card-title"]').textContent).toBe('Laptop');
expect(fixture.nativeElement.querySelector('[data-testid="card-price"]').textContent).toContain('999');
});
it('should emit cartAdd with product.id when button clicked', () => {
let emittedId: number | undefined;
component.cartAdd.subscribe((id: number) => emittedId = id);
fixture.nativeElement.querySelector('[data-testid="add-btn"]').click();
expect(emittedId).toBe(1);
});
it('should show favorite button only when showFavorite=true', () => {
component.showFavorite = false;
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[data-testid="fav-btn"]')).toBeNull();
component.showFavorite = true;
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[data-testid="fav-btn"]')).toBeTruthy();
});
});
// โโ Test Parent + Child Integration โโโโโโโโโโโโโโโโโโโโโโโโโโโ
describe('ProductListComponent (Parent with Child)', () => {
let fixture: ComponentFixture;
let component: ProductListComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ProductListComponent, ProductCardComponent],
imports: [CommonModule]
}).compileComponents();
fixture = TestBed.createComponent(ProductListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should render two product cards', () => {
const cards = fixture.debugElement.queryAll(By.directive(ProductCardComponent));
expect(cards.length).toBe(2);
});
it('should pass correct product to child via @Input', () => {
const cards = fixture.debugElement.queryAll(By.directive(ProductCardComponent));
const firstCard = cards[0].componentInstance as ProductCardComponent;
expect(firstCard.product.name).toBe('Laptop');
});
it('should update cartCount when child emits cartAdd', () => {
const cards = fixture.debugElement.queryAll(By.directive(ProductCardComponent));
const firstCard = cards[0].componentInstance as ProductCardComponent;
// Simulate child Output event
firstCard.cartAdd.emit(1);
fixture.detectChanges();
expect(component.cartCount).toBe(1);
expect(fixture.nativeElement.querySelector('[data-testid="cart-count"]').textContent).toContain('1');
});
it('should pass showFavorite=true to all child cards', () => {
const cards = fixture.debugElement.queryAll(By.directive(ProductCardComponent));
cards.forEach(card => {
expect((card.componentInstance as ProductCardComponent).showFavorite).toBeTrue();
});
});
// โโ ViewChild โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('should access first child via @ViewChild', () => {
// ViewChild is populated after detectChanges
expect(component.firstCard).toBeTruthy();
expect(component.firstCard).toBeInstanceOf(ProductCardComponent);
});
});
- Test child components in isolation first (set @Input manually, subscribe to @Output). Then test the integration via a parent.
- Use
By.directive(ChildComponent)to query all instances of a child component inside a parent fixture. - Access a child's component instance via
debugElement.componentInstanceto read @Input values or trigger @Output events directly. @ViewChildis populated only afterfixture.detectChanges()is called โ don't access it before.- Emitting from child:
childInstance.cartAdd.emit(value)โ this triggers the parent's(cartAdd)="handler($event)"binding.
Jasmine Matchers
| toBe(x) | Strict === equality (primitives) |
| toEqual(x) | Deep equality (objects/arrays) |
| toBeTrue() / toBeFalse() | Boolean assertions |
| toBeTruthy() / toBeFalsy() | Truthy/falsy checks |
| toBeNull() / toBeDefined() | Null/undefined checks |
| toBeUndefined() | Strictly undefined |
| toContain(x) | String/array contains x |
| toMatch(/regex/) | String matches regex |
| toBeGreaterThan(n) | Numeric comparison |
| toBeLessThan(n) | Numeric comparison |
| toThrow() | Function throws any error |
| toThrowError('msg') | Throws error with message |
| toThrowError(ErrorClass) | Throws specific error type |
| toHaveBeenCalled() | Spy was called at least once |
| toHaveBeenCalledTimes(n) | Spy called exactly n times |
| toHaveBeenCalledWith(...args) | Spy called with args |
| toBeInstanceOf(Class) | instanceof check |
| not.matcher() | Negates any matcher |
TestBed Methods
| TestBed.configureTestingModule({}) | Set up test module |
| .compileComponents() | Compile async (await this) |
| TestBed.createComponent(C) | Creates ComponentFixture |
| TestBed.inject(Token) | Get service from DI |
| fixture.detectChanges() | Run change detection |
| fixture.componentInstance | Component class instance |
| fixture.nativeElement | Root DOM element |
| fixture.debugElement | DebugElement wrapper |
| debugElement.query(By.css()) | Find first matching element |
| debugElement.queryAll(By.css()) | Find all matching elements |
| By.css('selector') | CSS selector predicate |
| By.directive(Directive) | Directive predicate |
| debugElement.triggerEventHandler() | Fire host listener |
| httpMock.expectOne(url) | Assert one HTTP request |
| httpMock.verify() | No unexpected requests |
| req.flush(data) | Provide mock response |
| fakeAsync(() => {}) | Synchronous async wrapper |
| tick(ms) | Advance virtual clock |
| flushMicrotasks() | Flush Promise queue |
Key Imports Cheat Sheet
| TestBed, ComponentFixture | @angular/core/testing |
| fakeAsync, tick, flush | @angular/core/testing |
| flushMicrotasks | @angular/core/testing |
| By | @angular/platform-browser |
| HttpClientTestingModule | @angular/common/http/testing |
| HttpTestingController | @angular/common/http/testing |
| RouterTestingModule | @angular/router/testing |
| Location | @angular/common |
| convertToParamMap | @angular/router |
| provideMockActions | @ngrx/effects/testing |
| MockStore, provideMockStore | @ngrx/store/testing |
| NO_ERRORS_SCHEMA | @angular/core |
| DebugElement | @angular/core |
| cold, hot | jasmine-marbles |
Spy Quick Reference
| jasmine.createSpyObj('N', ['m1','m2']) | Create full mock object |
| spyOn(obj, 'method') | Spy on existing method |
| spyOnProperty(obj, 'prop', 'get') | Spy on getter/setter |
| .and.returnValue(x) | Return fixed value |
| .and.callFake(fn) | Replace with custom fn |
| .and.callThrough() | Spy + real implementation |
| .and.throwError('msg') | Spy throws error |
| spy.calls.count() | Number of calls |
| spy.calls.mostRecent() | Most recent call info |
| spy.calls.allArgs() | Array of all call args |
| spy.calls.reset() | Reset call history |