Skip to main content

Angular Service Testing

Introduction

Services are a fundamental part of Angular applications, encapsulating business logic, data fetching, state management, and other non-UI functionalities. Testing services thoroughly is crucial because they often contain the core logic of your application. In this guide, we'll learn how to write effective unit tests for Angular services using the Jasmine testing framework and the Angular testing utilities.

Why Test Services?

Services in Angular applications:

  • Manage data and application state
  • Handle API communication
  • Implement business logic
  • Are often shared across multiple components

By testing services, you can:

  • Verify that business logic works as expected
  • Ensure API calls are made correctly
  • Validate data transformations
  • Identify bugs early in the development process

Setting Up for Service Testing

To test an Angular service, you'll need:

  1. The service to test
  2. TestBed for configuring a testing module
  3. Dependencies to provide (real or mock versions)

Let's start by looking at a simple service:

typescript
// data.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

@Injectable({
providedIn: 'root'
})
export class DataService {
private apiUrl = 'https://api.example.com/data';

constructor(private http: HttpClient) { }

getData(): Observable<any[]> {
return this.http.get<any[]>(this.apiUrl);
}

getItemById(id: number): Observable<any> {
return this.http.get<any>(`${this.apiUrl}/${id}`);
}

addItem(item: any): Observable<any> {
return this.http.post<any>(this.apiUrl, item);
}

processData(data: any[]): any[] {
return data.map(item => ({
...item,
processed: true
}));
}
}

Basic Service Test Setup

Here's how you can set up a basic test for this service:

typescript
// data.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { DataService } from './data.service';

describe('DataService', () => {
let service: DataService;
let httpMock: HttpTestingController;

beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [DataService]
});

service = TestBed.inject(DataService);
httpMock = TestBed.inject(HttpTestingController);
});

afterEach(() => {
httpMock.verify(); // Verify that no unmatched requests are outstanding
});

it('should be created', () => {
expect(service).toBeTruthy();
});
});

This setup:

  1. Configures a test module with HttpClientTestingModule (a mock implementation of HttpClient)
  2. Injects both the service and the HttpTestingController
  3. Verifies after each test that there are no outstanding HTTP requests
  4. Includes a basic test that verifies the service is created

Testing HTTP Requests

One of the most common things to test in services is HTTP requests. Here's how to test the getData() method:

typescript
it('should retrieve data from the API', () => {
const mockData = [
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' }
];

// Call the method that makes an HTTP request
service.getData().subscribe(data => {
expect(data).toEqual(mockData);
});

// Expect a request to this URL
const req = httpMock.expectOne('https://api.example.com/data');

// Assert that the request is a GET
expect(req.request.method).toBe('GET');

// Respond with mock data
req.flush(mockData);
});

This test:

  1. Creates mock data to return
  2. Calls the service method and subscribes to the result
  3. Verifies that an HTTP GET request was made to the correct URL
  4. Provides mock data as the response
  5. Checks that the service correctly returned the mock data

Testing Methods with Parameters

To test the getItemById() method:

typescript
it('should retrieve a specific item by id', () => {
const mockItem = { id: 1, name: 'Item 1' };
const itemId = 1;

service.getItemById(itemId).subscribe(item => {
expect(item).toEqual(mockItem);
});

const req = httpMock.expectOne(`https://api.example.com/data/${itemId}`);
expect(req.request.method).toBe('GET');
req.flush(mockItem);
});

Testing POST Requests

For testing the addItem() method:

typescript
it('should add an item via POST request', () => {
const newItem = { name: 'New Item' };
const mockResponse = { id: 3, name: 'New Item' };

service.addItem(newItem).subscribe(response => {
expect(response).toEqual(mockResponse);
});

const req = httpMock.expectOne('https://api.example.com/data');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual(newItem); // Verify the request body
req.flush(mockResponse);
});

This test also verifies that the correct data was sent in the request body.

Testing Pure Logic Methods

For the processData() method, which doesn't involve HTTP requests, we can test it directly:

typescript
it('should process data correctly', () => {
const inputData = [
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' }
];

const expected = [
{ id: 1, name: 'Item 1', processed: true },
{ id: 2, name: 'Item 2', processed: true }
];

const result = service.processData(inputData);
expect(result).toEqual(expected);
});

Testing Error Handling

It's also important to test how your service handles errors:

typescript
it('should handle errors when the API returns an error', () => {
// Arrange
service.getData().subscribe(
() => fail('should have failed with an error'),
(error) => {
expect(error.status).toBe(500);
}
);

// Act & Assert
const req = httpMock.expectOne('https://api.example.com/data');
req.flush('Server error', { status: 500, statusText: 'Internal Server Error' });
});

Testing Services with Dependencies

Often, services depend on other services. Let's see how to test a service that depends on our DataService:

typescript
// user.service.ts
import { Injectable } from '@angular/core';
import { DataService } from './data.service';
import { Observable, map } from 'rxjs';

@Injectable({
providedIn: 'root'
})
export class UserService {
constructor(private dataService: DataService) {}

getActiveUsers(): Observable<any[]> {
return this.dataService.getData().pipe(
map(users => users.filter(user => user.isActive))
);
}
}

Here's how we can test it using a spy for the DataService:

typescript
// user.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { UserService } from './user.service';
import { DataService } from './data.service';

describe('UserService', () => {
let userService: UserService;
let dataServiceSpy: jasmine.SpyObj<DataService>;

beforeEach(() => {
// Create a spy for DataService
const spy = jasmine.createSpyObj('DataService', ['getData']);

TestBed.configureTestingModule({
providers: [
UserService,
{ provide: DataService, useValue: spy }
]
});

userService = TestBed.inject(UserService);
dataServiceSpy = TestBed.inject(DataService) as jasmine.SpyObj<DataService>;
});

it('should filter active users', () => {
// Mock data
const mockUsers = [
{ id: 1, name: 'Alice', isActive: true },
{ id: 2, name: 'Bob', isActive: false },
{ id: 3, name: 'Charlie', isActive: true }
];

// Setup the spy to return the mock data
dataServiceSpy.getData.and.returnValue(of(mockUsers));

// Call the method
userService.getActiveUsers().subscribe(activeUsers => {
expect(activeUsers.length).toBe(2);
expect(activeUsers[0].name).toBe('Alice');
expect(activeUsers[1].name).toBe('Charlie');
});

// Verify the spy was called
expect(dataServiceSpy.getData).toHaveBeenCalled();
});
});

Best Practices for Service Testing

  1. Test one thing per test: Each test should focus on a specific behavior.
  2. Use descriptive test names: Make it clear what each test is verifying.
  3. Mock external dependencies: Don't rely on real HTTP calls or other services.
  4. Test error conditions: Make sure your service handles errors gracefully.
  5. Verify both the result and the process: Check both the returned value and that the correct API calls were made.
  6. Clean up after tests: Use afterEach to verify there are no outstanding HTTP requests.

Real-world Example: Authentication Service

Let's test a more complex authentication service that manages user login state:

typescript
// auth.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, Observable, of, throwError } from 'rxjs';
import { tap, catchError } from 'rxjs/operators';

interface User {
id: number;
username: string;
token: string;
}

@Injectable({
providedIn: 'root'
})
export class AuthService {
private currentUserSubject = new BehaviorSubject<User | null>(null);
public currentUser$ = this.currentUserSubject.asObservable();
private apiUrl = 'https://api.example.com/auth';

constructor(private http: HttpClient) {
// Check if user is stored in localStorage
const storedUser = localStorage.getItem('currentUser');
if (storedUser) {
this.currentUserSubject.next(JSON.parse(storedUser));
}
}

login(username: string, password: string): Observable<User> {
return this.http.post<User>(`${this.apiUrl}/login`, { username, password })
.pipe(
tap(user => {
localStorage.setItem('currentUser', JSON.stringify(user));
this.currentUserSubject.next(user);
}),
catchError(error => {
return throwError(() => new Error('Invalid credentials'));
})
);
}

logout(): void {
localStorage.removeItem('currentUser');
this.currentUserSubject.next(null);
}

isAuthenticated(): boolean {
return !!this.currentUserSubject.value;
}
}

Now let's write comprehensive tests for this service:

typescript
// auth.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { AuthService } from './auth.service';

describe('AuthService', () => {
let service: AuthService;
let httpMock: HttpTestingController;

beforeEach(() => {
// Clear localStorage before each test
localStorage.clear();

TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [AuthService]
});

service = TestBed.inject(AuthService);
httpMock = TestBed.inject(HttpTestingController);
});

afterEach(() => {
httpMock.verify();
});

it('should be created', () => {
expect(service).toBeTruthy();
});

it('should login a user and store in localStorage', () => {
const mockUser = { id: 1, username: 'testuser', token: 'abc123' };
let currentUser: any = null;

service.currentUser$.subscribe(user => {
currentUser = user;
});

service.login('testuser', 'password').subscribe(user => {
expect(user).toEqual(mockUser);
expect(currentUser).toEqual(mockUser);
expect(localStorage.getItem('currentUser')).toBe(JSON.stringify(mockUser));
});

const req = httpMock.expectOne('https://api.example.com/auth/login');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual({ username: 'testuser', password: 'password' });
req.flush(mockUser);
});

it('should handle login errors', () => {
service.login('testuser', 'wrongpassword').subscribe(
() => fail('should have failed with an error'),
(error) => {
expect(error.message).toBe('Invalid credentials');
expect(localStorage.getItem('currentUser')).toBeNull();
}
);

const req = httpMock.expectOne('https://api.example.com/auth/login');
req.flush('Invalid credentials', { status: 401, statusText: 'Unauthorized' });
});

it('should logout and clear localStorage', () => {
// Setup: first login a user
const mockUser = { id: 1, username: 'testuser', token: 'abc123' };
localStorage.setItem('currentUser', JSON.stringify(mockUser));
(service as any).currentUserSubject.next(mockUser);

// Verify initial state
expect(service.isAuthenticated()).toBeTrue();

// Act: logout
service.logout();

// Assert: check user was logged out
let currentUser: any = {};
service.currentUser$.subscribe(user => {
currentUser = user;
});

expect(currentUser).toBeNull();
expect(localStorage.getItem('currentUser')).toBeNull();
expect(service.isAuthenticated()).toBeFalse();
});

it('should restore user from localStorage on initialization', () => {
// Arrange: Set a user in localStorage before creating service
const mockUser = { id: 1, username: 'testuser', token: 'abc123' };
localStorage.setItem('currentUser', JSON.stringify(mockUser));

// Act: Create a new instance of the service
const newService = TestBed.inject(AuthService);

// Assert: The service should have loaded the user from localStorage
let currentUser: any = null;
newService.currentUser$.subscribe(user => {
currentUser = user;
});

expect(currentUser).toEqual(mockUser);
expect(newService.isAuthenticated()).toBeTrue();
});
});

This comprehensive test suite covers:

  • Basic service creation
  • Successful login with proper state updates
  • Error handling during login
  • Logout functionality
  • Retrieving user state from localStorage on initialization

Summary

Testing Angular services is a crucial part of ensuring your application works correctly. By testing services, you verify that your business logic, data fetching, and state management work as expected.

In this guide, we've covered:

  • Basic setup for testing Angular services
  • Testing HTTP requests (GET, POST)
  • Testing pure logic functions
  • Testing services with dependencies
  • Testing error handling
  • Testing complex services with state management

Remember that effective service testing focuses on:

  • Verifying that services make correct API calls
  • Confirming that data transformations work as expected
  • Ensuring proper error handling
  • Checking state management functionality

Additional Resources

Exercises

  1. Create a simple TodoService with methods to fetch, add, update, and delete todos, and write tests for each method.
  2. Extend the AuthService to include a changePassword method and write tests for it.
  3. Create a service with methods that depend on the response from other methods in the same service, and write tests for this interdependency.
  4. Write a test for a service that handles pagination from an API endpoint.
  5. Create a service that uses multiple HTTP requests to compile data, and write tests to ensure all requests are made correctly and the data is properly combined.


If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)