Testing is the discipline that transforms Angular code from “it seems to work” to “it provably works under defined conditions.” Angular’s testing toolkit — Jasmine for test structure, Karma/Jest for execution, and TestBed for Angular-aware component and service testing — provides everything you need to write unit tests for components, integration tests for services, and end-to-end tests with Cypress or Playwright. This lesson focuses on the tests that provide the most value for the least effort: unit testing services with mocked dependencies, testing components with TestBed, and testing reactive forms.
Testing Levels in Angular
| Level | Tests | Tools | Speed |
|---|---|---|---|
| Unit tests | Individual service methods, pure functions, pipes, validators | Jasmine/Jest — no DOM | Very fast (< 1ms each) |
| Component tests | Component template rendering, bindings, user interaction | TestBed — real Angular DI + DOM | Fast (1–10ms each) |
| Integration tests | Multiple components working together | TestBed with real dependencies | Medium (10–100ms each) |
| E2E tests | Full user flows in a real browser | Cypress, Playwright | Slow (seconds each) |
TestBed API
| Method | Purpose |
|---|---|
TestBed.configureTestingModule(config) |
Set up the test module with imports, declarations, providers |
TestBed.createComponent(ComponentClass) |
Create a component instance with its host element |
TestBed.inject(Token) |
Get a service or value from the test injector |
fixture.detectChanges() |
Run change detection — update the DOM |
fixture.debugElement |
Angular’s wrapper around the host element |
fixture.nativeElement |
Raw DOM element |
fixture.debugElement.query(By.css('.sel')) |
Query descendant elements |
fixture.debugElement.queryAll(By.directive(Dir)) |
Query all matching descendants |
ng add jest or configure manually. The test syntax is nearly identical to Jasmine: describe, it, expect, beforeEach — but Jest’s matchers are slightly different (toHaveBeenCalledWith vs Jasmine’s toHaveBeenCalledOnceWith).jasmine.createSpyObj('ServiceName', ['method1', 'method2']) — it creates an object with all specified methods as spy functions that return undefined by default. Use spy.and.returnValue(of(mockData)) to make a method return an Observable. This approach is faster than HttpClientTestingModule for component tests where you just want to control what the service returns without testing HTTP behaviour.fixture.detectChanges() after setting up the component and after making changes to trigger template rendering. Without it, the template is not rendered, and queries for DOM elements return null. For async operations, use fakeAsync() + tick() or waitForAsync() + fixture.whenStable() to wait for all pending async operations before asserting. Testing async code without proper async handling leads to false positives.Complete Testing Examples
// ── Testing a service ─────────────────────────────────────────────────────
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { TaskService } from './task.service';
import { Task } from '../models/task.model';
import { API_BASE_URL } from '../tokens';
describe('TaskService', () => {
let service: TaskService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
TaskService,
{ provide: API_BASE_URL, useValue: 'http://localhost:3000/api/v1' },
],
});
service = TestBed.inject(TaskService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify(); // assert no unexpected HTTP requests
});
it('should fetch all tasks', () => {
const mockTasks: Task[] = [
{ _id: '1', title: 'Task 1', status: 'pending', priority: 'medium' } as Task,
];
service.getAll().subscribe(tasks => {
expect(tasks.length).toBe(1);
expect(tasks[0].title).toBe('Task 1');
});
const req = httpMock.expectOne('http://localhost:3000/api/v1/tasks');
expect(req.request.method).toBe('GET');
req.flush({ success: true, data: mockTasks }); // respond to the pending request
});
it('should handle 404 errors', () => {
service.getById('nonexistent').subscribe({
error: err => expect(err.status).toBe(404),
});
const req = httpMock.expectOne('http://localhost:3000/api/v1/tasks/nonexistent');
req.flush({ message: 'Not found' }, { status: 404, statusText: 'Not Found' });
});
});
// ── Testing a component with mocked service ───────────────────────────────
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { of, throwError } from 'rxjs';
import { TaskListComponent } from './task-list.component';
import { TaskCardComponent } from '../task-card/task-card.component';
import { TaskStore } from '../../core/stores/task.store';
import { RouterTestingModule } from '@angular/router/testing';
describe('TaskListComponent', () => {
let fixture: ComponentFixture<TaskListComponent>;
let component: TaskListComponent;
let taskStoreMock: jasmine.SpyObj<TaskStore>;
const mockTasks = [
{ _id: '1', title: 'First Task', status: 'pending', priority: 'high' } as Task,
{ _id: '2', title: 'Second Task', status: 'completed', priority: 'medium' } as Task,
];
beforeEach(async () => {
taskStoreMock = jasmine.createSpyObj('TaskStore', ['loadAll', 'delete', 'complete'], {
// Spy on signal getters
tasks: jasmine.createSpy().and.returnValue(mockTasks),
isLoading: jasmine.createSpy().and.returnValue(false),
error: jasmine.createSpy().and.returnValue(null),
});
await TestBed.configureTestingModule({
imports: [
TaskListComponent, // standalone component
RouterTestingModule,
],
providers: [
{ provide: TaskStore, useValue: taskStoreMock },
],
}).compileComponents();
fixture = TestBed.createComponent(TaskListComponent);
component = fixture.componentInstance;
fixture.detectChanges(); // run ngOnInit, render template
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should call loadAll on init', () => {
expect(taskStoreMock.loadAll).toHaveBeenCalledOnce();
});
it('should render task cards for each task', () => {
const cards = fixture.debugElement.queryAll(By.directive(TaskCardComponent));
expect(cards.length).toBe(2);
});
it('should render task titles', () => {
const titles = fixture.nativeElement.querySelectorAll('.task-title');
expect(titles[0].textContent).toContain('First Task');
});
it('should call store.delete when task is deleted', () => {
const card = fixture.debugElement.query(By.directive(TaskCardComponent));
card.triggerEventHandler('deleted', '1'); // simulate @Output event
expect(taskStoreMock.delete).toHaveBeenCalledWith('1');
});
it('should show loading spinner when loading', () => {
taskStoreMock.isLoading.and.returnValue(true);
fixture.detectChanges();
const spinner = fixture.nativeElement.querySelector('app-spinner');
expect(spinner).toBeTruthy();
});
});
// ── Testing a reactive form ───────────────────────────────────────────────
import { ReactiveFormsModule } from '@angular/forms';
import { TaskFormComponent } from './task-form.component';
describe('TaskFormComponent', () => {
let fixture: ComponentFixture<TaskFormComponent>;
let component: TaskFormComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TaskFormComponent, RouterTestingModule],
}).compileComponents();
fixture = TestBed.createComponent(TaskFormComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should be invalid when title is empty', () => {
component.form.get('title')!.setValue('');
expect(component.form.invalid).toBeTrue();
});
it('should be valid when required fields are filled', () => {
component.form.patchValue({ title: 'New Task', priority: 'high' });
expect(component.form.valid).toBeTrue();
});
it('should show title error after submitting with empty title', () => {
component.form.get('title')!.setValue('');
component.onSubmit(); // calls markAllAsTouched
fixture.detectChanges();
const error = fixture.nativeElement.querySelector('[data-testid="title-error"]');
expect(error?.textContent).toContain('required');
});
it('should disable submit button when form is invalid', () => {
component.form.get('title')!.setValue('');
fixture.detectChanges();
const btn = fixture.nativeElement.querySelector('button[type=submit]');
expect(btn.disabled).toBeTrue();
});
});
// ── Testing a pure pipe ───────────────────────────────────────────────────
import { RelativeDatePipe } from './relative-date.pipe';
describe('RelativeDatePipe', () => {
const pipe = new RelativeDatePipe();
it('returns "just now" for very recent dates', () => {
expect(pipe.transform(new Date())).toBe('just now');
});
it('returns "Xm ago" for minutes', () => {
const fiveMinAgo = new Date(Date.now() - 5 * 60000);
expect(pipe.transform(fiveMinAgo)).toBe('5m ago');
});
it('returns empty string for null', () => {
expect(pipe.transform(null)).toBe('');
});
});
How It Works
Step 1 — TestBed Creates an Angular Testing Module
TestBed.configureTestingModule() sets up a minimal Angular module for testing. You declare only what the component under test needs — its own imports (for standalone), mock services in providers, and any necessary testing utilities like RouterTestingModule. The testing module is isolated from the application module — each test suite gets a fresh module with a fresh injector.
Step 2 — fixture.detectChanges() Drives the Template
Angular’s testing environment does not automatically run change detection. You control when the template is rendered by calling fixture.detectChanges(). The first call triggers ngOnInit() and renders the template. Subsequent calls re-run change detection to apply changes you made (setting signal values, patching form controls). This explicit control makes tests predictable — you always know when the DOM was last updated.
Step 3 — Spy Objects Mock Services Without Real Implementations
jasmine.createSpyObj('Name', ['method1', 'method2']) creates a mock object with all specified methods as spies. Spies record how many times they are called, with what arguments, and can be configured to return specific values. spy.and.returnValue(of(data)) makes the spy return an Observable. This isolates the component from real HTTP calls and database operations, making tests fast and reliable.
Step 4 — By.css() and By.directive() Query the Template
fixture.debugElement.query(By.css('.my-class')) finds the first descendant element matching a CSS selector. queryAll(By.directive(TaskCardComponent)) finds all instances of a specific directive/component. The debugElement wraps the DOM element with Angular metadata — you can trigger Angular-aware events with debugElement.triggerEventHandler('click', {}), which goes through Angular’s event binding system rather than DOM events.
Step 5 — fakeAsync and tick Control Time
fakeAsync(() => { ... }) wraps a test in a zone that tracks async operations. Inside, tick(ms) advances the virtual clock by the specified milliseconds — resolving any pending setTimeouts, intervals, or Promises that would complete in that time. tick() (no argument) flushes all pending microtasks. This makes testing debounced form validation, animated state changes, and delayed API calls deterministic without real waiting.
Common Mistakes
Mistake 1 — Not calling fixture.detectChanges() before asserting
❌ Wrong — template not rendered, DOM queries return null:
fixture = TestBed.createComponent(MyComponent);
// Missing: fixture.detectChanges();
const el = fixture.nativeElement.querySelector('.title');
expect(el.textContent).toBe('Hello'); // el is null — template not rendered!
✅ Correct — always detectChanges() before asserting on the DOM:
fixture = TestBed.createComponent(MyComponent);
fixture.detectChanges(); // renders template, calls ngOnInit
const el = fixture.nativeElement.querySelector('.title');
expect(el?.textContent).toBe('Hello');
Mistake 2 — Testing implementation details instead of behaviour
❌ Wrong — tests break when refactoring even though behaviour is unchanged:
expect(component.privateFilterMethod()).toEqual([...]); // testing internal method
✅ Correct — test observable behaviour (template output, events):
component.filterStatus.set('pending');
fixture.detectChanges();
const cards = fixture.debugElement.queryAll(By.directive(TaskCardComponent));
expect(cards.length).toBe(2); // test the result, not the mechanism
Mistake 3 — Not using fakeAsync for async operations
❌ Wrong — test passes before async completes:
it('should debounce search', () => {
component.searchInput.setValue('task');
expect(mockService.search).toHaveBeenCalled(); // false! debounce not elapsed
});
✅ Correct — use fakeAsync + tick to advance time:
it('should debounce search', fakeAsync(() => {
component.searchInput.setValue('task');
tick(300); // advance past debounce
expect(mockService.search).toHaveBeenCalledWith('task');
}));
Quick Reference
| Task | Code |
|---|---|
| Configure test module | TestBed.configureTestingModule({ imports: [...], providers: [...] }) |
| Create component | fixture = TestBed.createComponent(MyComponent) |
| Get service | service = TestBed.inject(MyService) |
| Render template | fixture.detectChanges() |
| Query DOM | fixture.debugElement.query(By.css('.cls')) |
| Query all | fixture.debugElement.queryAll(By.directive(MyComp)) |
| Trigger event | debugEl.triggerEventHandler('click', {}) |
| Mock service | jasmine.createSpyObj('Svc', ['method']) |
| Mock Observable return | spy.method.and.returnValue(of(data)) |
| Test async | fakeAsync(() => { ...; tick(300); expect(...) }) |