Testing Angular Services with Jasmine: A Comprehensive Guide to Ensuring Reliable Logic
Testing Angular services is essential for ensuring that the business logic, data access, and shared functionality in your application are robust and reliable. Angular’s testing ecosystem, powered by Jasmine and TestBed, provides a powerful framework for writing unit tests to validate services. This guide offers a detailed, step-by-step exploration of testing Angular services with Jasmine, covering setup, testing service methods, mocking dependencies, handling HTTP requests, and managing asynchronous operations. By the end, you’ll have a thorough understanding of how to create comprehensive tests to build dependable Angular services.
This blog dives deeply into each concept, ensuring clarity and practical applicability while maintaining readability. We’ll incorporate internal links to related resources and provide actionable code examples. Let’s dive into testing Angular services with Jasmine.
Why Test Angular Services with Jasmine?
Services in Angular encapsulate reusable logic, such as API calls, state management, or utility functions, making them critical components to test. Testing services with Jasmine offers several benefits:
- Reliability: Ensures service methods produce expected results and handle edge cases.
- Maintainability: Supports safe refactoring by catching regressions early.
- Testability: Isolates logic for easier mocking and verification.
- Developer Confidence: Validates functionality, allowing changes without breaking dependent components.
- Quality Assurance: Improves code quality through test-driven development (TDD).
Jasmine, Angular’s default testing framework, provides a behavior-driven development (BDD) syntax for writing clear, readable tests. Combined with TestBed and HttpClientTestingModule, it enables testing services in isolation, including those with dependencies like HttpClient. Common testing scenarios include:
- Verifying service creation and method outputs.
- Testing logic with mocked dependencies.
- Simulating HTTP requests and responses.
- Handling asynchronous operations (e.g., observables).
For a foundational overview of Angular testing, see Angular Testing.
Setting Up an Angular Project for Testing
Angular projects come pre-configured with Jasmine and Karma for unit testing. Let’s set up a project and create a service to test.
Step 1: Create a New Angular Project
Use the Angular CLI to create a project:
ng new service-testing-demo
Navigate to the project directory:
cd service-testing-demo
The CLI sets up Jasmine, Karma, and a test configuration. For more details, see Angular: Create a New Project.
Step 2: Verify the Testing Setup
Check angular.json for the test configuration:
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "src/test.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.spec.json",
"karmaConfig": "karma.conf.js",
"assets": ["src/favicon.ico", "src/assets"],
"styles": ["src/styles.css"],
"scripts": []
}
}
- Jasmine: Provides describe, it, and expect for writing tests.
- Karma: Runs tests in a browser and reports results.
- TestBed: Configures testing modules for services and components.
Run the default tests:
ng test
This executes tests for AppComponent and displays results.
Step 3: Generate a Service
Create a service to manage todos:
ng generate service todo
Implementing the Service
Let’s implement TodoService to manage a todo list, including in-memory operations and API calls using HttpClient.
Step 1: Implement the Service
Update todo.service.ts:
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class TodoService {
private apiUrl = 'https://jsonplaceholder.typicode.com/todos';
private todos = [
{ id: 1, title: 'Learn Jasmine', completed: false }
];
constructor(private http: HttpClient) {}
getLocalTodos() {
return this.todos;
}
addLocalTodo(title: string) {
const newTodo = { id: this.todos.length + 1, title, completed: false };
this.todos.push(newTodo);
return newTodo;
}
getTodos(): Observable {
return this.http.get(this.apiUrl);
}
getTodoById(id: number): Observable {
return this.http.get(`${this.apiUrl}/${id}`);
}
}
- The service provides in-memory (getLocalTodos, addLocalTodo) and API-based (getTodos, getTodoById) methods.
- HttpClient is used for HTTP requests, returning observables.
Update app.module.ts to include HttpClientModule:
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { HttpClientModule } from '@angular/common/http';
import { AppComponent } from './app.component';
@NgModule({
declarations: [AppComponent],
imports: [BrowserModule, HttpClientModule],
bootstrap: [AppComponent]
})
export class AppModule {}
Writing Unit Tests for the Service
Let’s write Jasmine tests for TodoService to verify its in-memory and HTTP-based methods.
Step 1: Test In-Memory Methods
Update todo.service.spec.ts:
import { TestBed } from '@angular/core/testing';
import { TodoService } from './todo.service';
describe('TodoService', () => {
let service: TodoService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [TodoService]
});
service = TestBed.inject(TodoService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should return initial local todos', () => {
const todos = service.getLocalTodos();
expect(todos.length).toBe(1);
expect(todos[0].title).toBe('Learn Jasmine');
expect(todos[0].completed).toBeFalse();
});
it('should add a new local todo', () => {
const newTodo = service.addLocalTodo('Test Todo');
const todos = service.getLocalTodos();
expect(todos.length).toBe(2);
expect(newTodo.title).toBe('Test Todo');
expect(newTodo.completed).toBeFalse();
expect(todos[1]).toEqual(newTodo);
});
});
- TestBed.configureTestingModule: Sets up a testing module with TodoService.
- Tests:
- Verifies service creation.
- Checks retrieval of initial todos.
- Tests adding a new todo and its properties.
Run ng test to execute the tests.
Testing HTTP Methods with HttpClient
Testing HTTP-based methods requires mocking HttpClient using HttpClientTestingModule and HttpTestingController.
Step 1: Update the Test File
Update todo.service.spec.ts:
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { TodoService } from './todo.service';
describe('TodoService', () => {
let service: TodoService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [TodoService]
});
service = TestBed.inject(TodoService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify(); // Ensure no outstanding requests
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should fetch todos from API', () => {
const mockTodos = [
{ id: 1, title: 'Todo 1', completed: false },
{ id: 2, title: 'Todo 2', completed: true }
];
service.getTodos().subscribe(todos => {
expect(todos.length).toBe(2);
expect(todos).toEqual(mockTodos);
});
const req = httpMock.expectOne('https://jsonplaceholder.typicode.com/todos');
expect(req.request.method).toBe('GET');
req.flush(mockTodos);
});
it('should fetch a single todo by ID', () => {
const mockTodo = { id: 1, title: 'Todo 1', completed: false };
service.getTodoById(1).subscribe(todo => {
expect(todo).toEqual(mockTodo);
});
const req = httpMock.expectOne('https://jsonplaceholder.typicode.com/todos/1');
expect(req.request.method).toBe('GET');
req.flush(mockTodo);
});
it('should handle HTTP error when fetching todos', () => {
const errorMessage = 'Failed to fetch todos';
service.getTodos().subscribe({
next: () => fail('Should have failed'),
error: (error) => expect(error.message).toContain(errorMessage)
});
const req = httpMock.expectOne('https://jsonplaceholder.typicode.com/todos');
req.flush(errorMessage, { status: 404, statusText: 'Not Found' });
});
});
- HttpClientTestingModule: Mocks HTTP requests.
- HttpTestingController: Simulates API responses and errors.
- Tests:
- Verifies service creation.
- Tests fetching a list of todos.
- Tests fetching a single todo by ID.
- Simulates an HTTP error (404) and checks error handling.
- httpMock.verify() ensures no unexpected requests remain.
For more on testing HTTP calls, see Test HTTP Calls in Angular.
Run ng test to execute the tests.
Mocking Dependencies
If a service depends on another service, mock the dependency to isolate the service under test. Let’s add a dependency to TodoService.
Step 1: Create a Logger Service
ng generate service logger
In logger.service.ts:
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class LoggerService {
log(message: string) {
console.log(`[LOG] ${message}`);
}
}
Step 2: Update TodoService
Update todo.service.ts:
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { LoggerService } from './logger.service';
@Injectable({
providedIn: 'root'
})
export class TodoService {
private apiUrl = 'https://jsonplaceholder.typicode.com/todos';
private todos = [
{ id: 1, title: 'Learn Jasmine', completed: false }
];
constructor(
private http: HttpClient,
private logger: LoggerService
) {}
getLocalTodos() {
this.logger.log('Fetching local todos');
return this.todos;
}
addLocalTodo(title: string) {
this.logger.log(`Adding todo: ${title}`);
const newTodo = { id: this.todos.length + 1, title, completed: false };
this.todos.push(newTodo);
return newTodo;
}
getTodos(): Observable {
this.logger.log('Fetching todos from API');
return this.http.get(this.apiUrl);
}
getTodoById(id: number): Observable {
this.logger.log(`Fetching todo with ID: ${id}`);
return this.http.get(`${this.apiUrl}/${id}`);
}
}
Step 3: Update Tests with Mocked Dependency
Update todo.service.spec.ts:
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { TodoService } from './todo.service';
import { LoggerService } from './logger.service';
describe('TodoService', () => {
let service: TodoService;
let httpMock: HttpTestingController;
let loggerMock: jasmine.SpyObj;
beforeEach(() => {
loggerMock = jasmine.createSpyObj('LoggerService', ['log']);
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
TodoService,
{ provide: LoggerService, useValue: loggerMock }
]
});
service = TestBed.inject(TodoService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should log when fetching local todos', () => {
const todos = service.getLocalTodos();
expect(loggerMock.log).toHaveBeenCalledWith('Fetching local todos');
expect(todos.length).toBe(1);
});
it('should log and fetch todos from API', () => {
const mockTodos = [
{ id: 1, title: 'Todo 1', completed: false }
];
service.getTodos().subscribe(todos => {
expect(todos).toEqual(mockTodos);
});
expect(loggerMock.log).toHaveBeenCalledWith('Fetching todos from API');
const req = httpMock.expectOne('https://jsonplaceholder.typicode.com/todos');
req.flush(mockTodos);
});
});
- jasmine.createSpyObj creates a mock LoggerService with a spied log method.
- { provide: LoggerService, useValue: loggerMock } injects the mock.
- Tests verify that the logger is called and service methods work as expected.
For more on mocking, see Mock Services in Unit Tests.
Testing Error Handling
Let’s add error handling to TodoService and test it.
Step 1: Update the Service with Error Handling
Update todo.service.ts:
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { LoggerService } from './logger.service';
@Injectable({
providedIn: 'root'
})
export class TodoService {
private apiUrl = 'https://jsonplaceholder.typicode.com/todos';
constructor(
private http: HttpClient,
private logger: LoggerService
) {}
getLocalTodos() {
this.logger.log('Fetching local todos');
return this.todos;
}
addLocalTodo(title: string) {
this.logger.log(`Adding todo: ${title}`);
const newTodo = { id: this.todos.length + 1, title, completed: false };
this.todos.push(newTodo);
return newTodo;
}
getTodos(): Observable {
this.logger.log('Fetching todos from API');
return this.http.get(this.apiUrl).pipe(
catchError(this.handleError('fetch todos'))
);
}
getTodoById(id: number): Observable {
this.logger.log(`Fetching todo with ID: ${id}`);
return this.http.get(`${this.apiUrl}/${id}`).pipe(
catchError(this.handleError(`fetch todo with ID ${id}`))
);
}
private handleError(operation: string) {
return (error: any) => {
this.logger.log(`Error ${operation}: ${error.message}`);
return throwError(() => new Error(`Failed to ${operation}: ${error.message}`));
};
}
}
Step 2: Update Tests for Error Handling
Add to todo.service.spec.ts:
it('should handle API error and log', () => {
const errorMessage = 'API error';
service.getTodos().subscribe({
next: () => fail('Should have failed'),
error: (error) => expect(error.message).toContain('Failed to fetch todos')
});
const req = httpMock.expectOne('https://jsonplaceholder.typicode.com/todos');
req.flush(errorMessage, { status: 500, statusText: 'Server Error' });
expect(loggerMock.log).toHaveBeenCalledWith('Fetching todos from API');
expect(loggerMock.log).toHaveBeenCalledWith(`Error fetch todos: ${errorMessage}`);
});
- The test simulates a 500 error and verifies that the error is logged and propagated.
- catchError and handleError ensure proper error management.
For more on error handling, see Handle Errors in HTTP Calls.
Testing Asynchronous Operations
Since getTodos and getTodoById return observables, let’s test async behavior using fakeAsync.
Add to todo.service.spec.ts:
import { fakeAsync, tick } from '@angular/core/testing';
it('should fetch todos asynchronously', fakeAsync(() => {
const mockTodos = [
{ id: 1, title: 'Async Todo', completed: false }
];
let result: any[];
service.getTodos().subscribe(todos => result = todos);
const req = httpMock.expectOne('https://jsonplaceholder.typicode.com/todos');
req.flush(mockTodos);
tick();
expect(result).toEqual(mockTodos);
}));
- fakeAsync and tick control asynchronous operations, ensuring the observable resolves before assertions.
- The test verifies that todos are fetched correctly.
For more on async testing, see Test HTTP Calls in Angular.
FAQs
Why test Angular services with Jasmine?
Jasmine provides a clear, BDD-style syntax for testing service logic, ensuring reliability and maintainability of business logic and data operations.
How do I set up service tests?
Use TestBed to configure a testing module, provide the service and its dependencies, and inject the service for testing.
How do I mock dependencies in service tests?
Use jasmine.createSpyObj or { provide: Service, useValue: mock } to supply mocked services with spied methods or predefined data.
How do I test HTTP calls in services?
Use HttpClientTestingModule and HttpTestingController to mock HTTP requests, simulate responses, and verify request details.
How do I test asynchronous operations in services?
Use fakeAsync and tick to control async tasks, or async/await with whenStable to handle observables and promises.
Conclusion
Testing Angular services with Jasmine ensures that your application’s logic and data operations are reliable and maintainable. This guide covered setting up a testing environment, writing unit tests for service methods, mocking dependencies, testing HTTP calls, handling errors, and managing async operations, providing a comprehensive approach to service testing.
To deepen your knowledge, explore related topics like Test Components with Jasmine for component testing, Mock Services in Unit Tests for advanced mocking, or Create E2E Tests with Cypress for end-to-end testing. With Jasmine and TestBed, you can craft high-quality, testable Angular services tailored to your needs.