Testing Angular Apps with Jasmine and Karma
Testing is a first-class citizen in Angular. The framework ships with Jasmine (the test framework), Karma (the test runner), and TestBed (Angular's testing utility) configured out of the box.
This guide covers unit testing services, pipes, and other non-visual logic. For component-specific testing see the Component Testing page.
Testing Tools Overview
Tool | Role |
|---|---|
Jasmine | Test framework — describe, it, expect, spyOn |
Karma | Test runner — runs tests in a real browser |
TestBed | Angular testing module — configures DI, components, etc. |
Angular CDK testing | Component harnesses for stable testing |
Jest | Popular alternative to Karma (faster, no browser) |
Running Tests
# Run all tests ng test # Run with code coverage ng test --code-coverage # Run specific file ng test --include='**/my-service.spec.ts' # Run in headless mode (CI) ng test --watch=false --browsers=ChromeHeadless
Chrome Headless 120.0.0 (Linux): Executed 42 of 42 SUCCESS (1.234 secs / 1.001 secs) TOTAL: 42 SUCCESS Coverage summary Statements : 94.2% ( 162/172 ) Branches : 88.9% ( 48/54 ) Functions : 96.7% ( 29/30 ) Lines : 94.1% ( 160/170 )
Jasmine Fundamentals
// Basic Jasmine structure
describe('Calculator', () => {
let calc: Calculator;
beforeEach(() => {
calc = new Calculator();
});
afterEach(() => {
// cleanup after each test
});
it('should add two numbers', () => {
expect(calc.add(2, 3)).toBe(5);
});
it('should subtract numbers', () => {
expect(calc.subtract(10, 4)).toBe(6);
});
it('should throw on division by zero', () => {
expect(() => calc.divide(10, 0)).toThrowError('Cannot divide by zero');
});
});Common Jasmine Matchers
// Equality
expect(value).toBe(5); // Strict equality (===)
expect(value).toEqual({ a: 1 }); // Deep equality
// Truthiness
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();
// Numbers
expect(value).toBeGreaterThan(3);
expect(value).toBeLessThanOrEqual(10);
expect(value).toBeCloseTo(3.14, 2); // 2 decimal places
// Strings / Arrays
expect('hello world').toContain('world');
expect([1, 2, 3]).toContain(2);
expect(arr).toHaveSize(3);
// Errors
expect(() => fn()).toThrow();
expect(() => fn()).toThrowError('message');
// Negation
expect(value).not.toBe(null);Testing Services
// calculator.service.ts
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class CalculatorService {
add(a: number, b: number): number {
return a + b;
}
multiply(a: number, b: number): number {
return a * b;
}
}// calculator.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { CalculatorService } from './calculator.service';
describe('CalculatorService', () => {
let service: CalculatorService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(CalculatorService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should add two positive numbers', () => {
expect(service.add(3, 4)).toBe(7);
});
it('should handle negative numbers', () => {
expect(service.add(-1, -2)).toBe(-3);
});
it('should multiply numbers', () => {
expect(service.multiply(4, 5)).toBe(20);
});
});Testing Services with Dependencies
// user.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface User {
id: number;
name: string;
email: string;
}
@Injectable({ providedIn: 'root' })
export class UserService {
private http = inject(HttpClient);
getUsers(): Observable<User[]> {
return this.http.get<User[]>('/api/users');
}
getUserById(id: number): Observable<User> {
return this.http.get<User>(`/api/users/${id}`);
}
createUser(user: Partial<User>): Observable<User> {
return this.http.post<User>('/api/users', user);
}
}// user.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { UserService, User } from './user.service';
describe('UserService', () => {
let service: UserService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
});
service = TestBed.inject(UserService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify(); // Ensures no unexpected requests were made
});
it('should fetch all users', () => {
const mockUsers: User[] = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
];
service.getUsers().subscribe((users) => {
expect(users).toHaveSize(2);
expect(users[0].name).toBe('Alice');
});
const req = httpMock.expectOne('/api/users');
expect(req.request.method).toBe('GET');
req.flush(mockUsers);
});
it('should fetch user by id', () => {
const mockUser: User = { id: 1, name: 'Alice', email: 'alice@example.com' };
service.getUserById(1).subscribe((user) => {
expect(user.name).toBe('Alice');
});
const req = httpMock.expectOne('/api/users/1');
req.flush(mockUser);
});
it('should handle HTTP errors', () => {
service.getUsers().subscribe({
error: (err) => {
expect(err.status).toBe(404);
},
});
const req = httpMock.expectOne('/api/users');
req.flush('Not found', { status: 404, statusText: 'Not Found' });
});
});Spies and Mocks
Spies let you observe, stub, or replace function behavior without running real code:
describe('spyOn examples', () => {
it('should track calls with spyOn', () => {
const service = new CalculatorService();
const spy = spyOn(service, 'add').and.returnValue(99);
const result = service.add(2, 3);
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith(2, 3);
expect(result).toBe(99);
});
it('should use jasmine.createSpy for standalone spies', () => {
const callbackSpy = jasmine.createSpy('callback');
someFunction(callbackSpy);
expect(callbackSpy).toHaveBeenCalledTimes(1);
});
it('should create a spy object', () => {
const mockService = jasmine.createSpyObj('UserService', ['getUsers', 'createUser']);
mockService.getUsers.and.returnValue(of([]));
expect(mockService.getUsers).toBeDefined();
});
it('should spy on and call through', () => {
const service = new LoggingService();
spyOn(service, 'log').and.callThrough(); // calls original but also tracks
service.log('hello');
expect(service.log).toHaveBeenCalledWith('hello');
});
});Testing Pipes
// truncate.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({ name: 'truncate', standalone: true })
export class TruncatePipe implements PipeTransform {
transform(value: string, limit = 50, ellipsis = '...'): string {
if (!value) return '';
if (value.length <= limit) return value;
return value.substring(0, limit) + ellipsis;
}
}// truncate.pipe.spec.ts
import { TruncatePipe } from './truncate.pipe';
describe('TruncatePipe', () => {
let pipe: TruncatePipe;
beforeEach(() => {
pipe = new TruncatePipe(); // No need for TestBed — it's a pure class
});
it('should create', () => {
expect(pipe).toBeTruthy();
});
it('should not truncate short strings', () => {
expect(pipe.transform('Hello')).toBe('Hello');
});
it('should truncate long strings with default ellipsis', () => {
const input = 'A'.repeat(60);
const result = pipe.transform(input);
expect(result.length).toBe(53); // 50 chars + '...'
expect(result.endsWith('...')).toBe(true);
});
it('should use custom limit', () => {
expect(pipe.transform('Hello World', 5)).toBe('Hello...');
});
it('should use custom ellipsis', () => {
expect(pipe.transform('Hello World', 5, ' →')).toBe('Hello →');
});
it('should handle empty string', () => {
expect(pipe.transform('')).toBe('');
});
it('should handle null/undefined', () => {
expect(pipe.transform(null as any)).toBe('');
});
});Testing Observables
import { of, throwError } from 'rxjs';
import { fakeAsync, tick } from '@angular/core/testing';
describe('Observable testing', () => {
it('should handle synchronous observable with done', (done) => {
const obs$ = of(42);
obs$.subscribe({
next: (val) => {
expect(val).toBe(42);
done();
},
});
});
it('should test async observable with fakeAsync', fakeAsync(() => {
let result: number | undefined;
const obs$ = timer(1000).pipe(mapTo(99));
obs$.subscribe((val) => (result = val));
expect(result).toBeUndefined(); // Not resolved yet
tick(1000); // Advance timer by 1 second
expect(result).toBe(99);
}));
it('should test error handling', () => {
const errorService = jasmine.createSpyObj('DataService', ['getData']);
errorService.getData.and.returnValue(throwError(() => new Error('Network error')));
let errorMessage = '';
errorService.getData().subscribe({
error: (err: Error) => (errorMessage = err.message),
});
expect(errorMessage).toBe('Network error');
});
});Code Coverage
ng test --code-coverage --watch=false
Coverage reports are generated in coverage/. Open coverage/my-app/index.html in a browser.
Configure coverage thresholds in karma.conf.js:
// karma.conf.js
module.exports = function(config) {
config.set({
coverageReporter: {
dir: require('path').join(__dirname, './coverage/my-app'),
subdir: '.',
reporters: [
{ type: 'html' },
{ type: 'text-summary' },
{ type: 'lcovonly' }
],
check: {
global: {
statements: 80,
branches: 80,
functions: 80,
lines: 80,
},
},
},
});
};Migrating to Jest
Jest is faster than Karma because it doesn't require a browser. Angular 16+ supports Jest:
ng add @angular-builders/jest
// jest.config.ts
export default {
preset: 'jest-preset-angular',
setupFilesAfterFramework: ['<rootDir>/setup-jest.ts'],
testEnvironment: 'jsdom',
moduleNameMapper: {
'^@app/(.*)$': '<rootDir>/src/app/$1',
},
};