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

01 of 18

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
// 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
// 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);
  });
});
๐Ÿ’ก Tips & Best Practices
  • Don't call fixture.detectChanges() before testing default values โ€” it triggers ngOnInit.
  • Use toBe() for primitives (strict ===) and toEqual() 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 of toBe(false) โ€” they give clearer failure messages.
02 of 18

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
// 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
// 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
  });
});
๐Ÿ’ก TestBed Essentials
  • 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-browser for Angular-aware DOM queries. Use fixture.nativeElement.querySelector() for simple cases.
  • Prefer data-testid attributes for selecting test elements โ€” they survive refactors better than CSS classes.
03 of 18

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
// 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
// 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();
  });
});
๐Ÿ’ก DOM Assertion Patterns
  • *ngIf physically adds/removes elements from DOM. Use query(...) and check if result is null vs truthy.
  • For *ngFor, use queryAll(By.css(...)) and check .length, not the component array directly.
  • Always call fixture.detectChanges() after mutating component state to sync the DOM.
  • CommonModule must be imported in TestBed if using *ngIf / *ngFor directives in a non-standalone component.
04 of 18

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
// 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
// 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
  });
});
๐Ÿ’ก Service Testing Tips
  • Use TestBed.inject(ServiceName) instead of new ServiceName() โ€” it works correctly when the service has its own dependencies.
  • Each beforeEach creates 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 uses inject() internally.
05 of 18

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
// 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
// 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();
  });
});
โš ๏ธ Common Gotchas
  • Always call httpMock.verify() in afterEach โ€” 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 form expectOne(r => ...) to check params, headers, and method.
  • Call req.flush(data) to provide mock response data. Call req.flush(msg, {status: 4xx, statusText: '...'}) to simulate HTTP errors.
  • Call req.error(new ProgressEvent('error')) to simulate a network-level error (no response).
06 of 18

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
// 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
// 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();
  });
});
๐Ÿ’ก Interceptor Testing Pattern
  • Register interceptors in TestBed via { provide: HTTP_INTERCEPTORS, useClass: YourInterceptor, multi: true }.
  • Test the interceptor by making real HttpClient calls โ€” not by calling the interceptor's intercept() 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.
07 of 18

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
// 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
// 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 vs Impure Pipes
  • Pure pipes can be tested with new PipeName() โ€” no TestBed needed. They are pure functions: same input โ†’ same output.
  • Impure pipes often depend on ChangeDetectorRef or 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 โ€” use toContain() or regex instead of exact string matching.
08 of 18

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
// 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
// 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();
  });
});
๐Ÿ’ก Directive Testing Tips
  • 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 (*ngIf style), check whether elements are present (toBeTruthy()) or absent (toBeNull()) in the DOM.
09 of 18

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
// 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
// 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();
  });
});
๐Ÿ’ก Guard Testing Tips
  • Guards return boolean | UrlTree | Observable | Promise. Test each case separately.
  • Cast ActivatedRouteSnapshot and RouterStateSnapshot as needed: { data: { role: 'admin' } } as unknown as ActivatedRouteSnapshot
  • Use router.serializeUrl(urlTree) to convert UrlTree to a readable string for assertions.
  • RouterTestingModule provides a real Router instance without requiring actual routes โ€” it handles createUrlTree().
10 of 18

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
// 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
// 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();
      }
    });
  });
});
๐Ÿ’ก Resolver Testing Pattern
  • Use convertToParamMap({ id: '42' }) to create a proper ParamMap for ActivatedRouteSnapshot.paramMap.
  • When a resolver returns EMPTY, subscribe with a complete callback to know when it finishes.
  • Always cast the second argument (RouterStateSnapshot) as {} as any in tests โ€” resolvers rarely use it.
  • Test that invalid IDs prevent the HTTP call entirely โ€” the service should not be called.
11 of 18

Routing

Testing routerLink hrefs, programmatic navigation, routerLinkActive, and router-outlet rendering. RouterTestingModule gives you a testable Router without a real browser history.

๐Ÿ“„ nav.component.ts
// 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
// 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');
  }));
});
๐Ÿ’ก Routing Test Essentials
  • Use RouterTestingModule.withRoutes([...]) to register test routes. Use stub components to avoid pulling in real component dependencies.
  • Wrap navigation assertions in fakeAsync and call tick() โ€” navigation is async.
  • Use Location.path() (from @angular/common) to check the current URL after navigation.
  • Test routerLinkActive by navigating first, then calling fixture.detectChanges() to apply the CSS class.
12 of 18

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
// 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
// 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);
  }));
});
๐Ÿ’ก RxJS Testing Strategies
  • done() โ€” Use for one-shot async operations. Jasmine waits until done() 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.
  • BehaviorSubject emits the current value to new subscribers immediately โ€” test this by subscribing after emitting.
  • Accessing private properties in tests: service['privateProp'] โ€” acceptable in Jasmine, though any cast is cleaner.
13 of 18

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
// 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
// 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
  }));
});
โš ๏ธ Common Gotchas
  • fakeAsync wraps the test to give you synchronous control over async operations.
  • tick(ms) advances the virtual clock โ€” use it for setTimeout, setInterval, and RxJS time-based operators like debounceTime/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 fakeAsync test, Jasmine will throw "X timer(s) still in the queue".
14 of 18

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
// 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
// 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);
  });
});
๐Ÿ’ก Signals Testing Tips
  • Read signals by calling them as functions: mySignal() โ€” not mySignal.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.
15 of 18

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.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
// 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();
    });
  });
});
๐Ÿ’ก NgRx Testing Strategy
  • 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 provideMockActions from @ngrx/effects/testing and a ReplaySubject to dispatch test actions.
  • Use MockStore from @ngrx/store/testing when testing components that use the store.
  • Test effects for both the happy path (Success action) and error path (Failure action).
16 of 18

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.

๐Ÿ“„ payment.service.ts
// 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
// 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'); }); });
๐Ÿ’ก Error Assertion Patterns
  • 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/catch with expect(e).toBeInstanceOf(MyError).
  • Use fail('message') to explicitly fail a test if an expected code path was not reached.
17 of 18

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
// 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
// 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);
  });
});
๐Ÿ’ก Spy API Reference
  • 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.
18 of 18

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.

๐Ÿ“„ product.components.ts
// 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
// 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);
  });
});
๐Ÿ’ก Parent-Child Testing Strategy
  • 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.componentInstance to read @Input values or trigger @Output events directly.
  • @ViewChild is populated only after fixture.detectChanges() is called โ€” don't access it before.
  • Emitting from child: childInstance.cartAdd.emit(value) โ€” this triggers the parent's (cartAdd)="handler($event)" binding.
๐Ÿ“‹ Quick Reference Cheat Sheet

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.componentInstanceComponent class instance
fixture.nativeElementRoot DOM element
fixture.debugElementDebugElement 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, hotjasmine-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