When testing an Angular service that has a dependency on HttpClient, it's important to avoid making actual HTTP requests during tests to ensure that tests are fast, predictable(Predictable responses), isolated(Isolation of Tests - tests are not dependent on external APIs) and do not have dependencies on external services(Avoidance of External Dependencies). To test such services, we use:
HttpClientTestingModule, which provides a mechanism to mock the behavior of the HttpClient without making actual HTTP requests.
HttpTestingController is a service provided by HttpClientTestingModule in Angular for testing HTTP requests. It allows you to intercept and assert HTTP requests made by your application during unit tests. With HttpTestingController, you can verify the number and properties of requests, as well as simulate responses to those requests.
Here is a basic structure from where we can start testing a Service having http calls in it's functions:
import { TestBed } from "@angular/core/testing";
import { CoursesService } from "./courses.service";
import {
HttpClientTestingModule,
HttpTestingController,
} from "@angular/common/http/testing";
describe("Course Service Test Suit", () => {
let coursesService: CoursesService,
httpTestingController: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [CoursesService],
});
//Creating an instance of Courses Service
coursesService = TestBed.inject(CoursesService);
//Creating an instance of HttpTestingController, that will be used to intercept and assert HTTP requests
httpTestingController = TestBed.inject(HttpTestingController);
});
});Here is the gist for the service file for which I have created the upcoming test cases: https://gist.github.com/gurindernarang/a9d129cab6641c3e18e180926e2b3020
Testing a function which will get Data from an API
//this will test if serivce is rendered properly
it("service should have created successfully", () => {
expect(coursesService).toBeTruthy();
});
//Will test if courses are returned by the api
it("Should retrieve all courses function", () => {
//Invokes the findAllCourses method on the coursesService. This is the method being tested.
coursesService.findAllCourses().subscribe(courses => {
//Ensures that the courses array is truthy, meaning it is not null or undefined. If it is falsy, the test will fail with the specified message "No courses returned".
expect(courses).toBeTruthy('No courses return');
//Expects that the length of the courses array is equal to 12. If not, the test will fail with the specified message "Invalid number of courses".
expect(courses?.length).toEqual(12, "Invalid number of courses");
});
//Expects that there is exactly one HTTP request made to the specified URL ('/api/courses'). If there is more or less than one request, the test will fail.
const apiReq = httpTestingController.expectOne('/api/courses');
//Expects that the HTTP request is not cancelled. If it is cancelled, the test will fail.
expect(apiReq.cancelled).toBeFalsy();
//Expects that the HTTP request method is 'GET'. If it is not, the test will fail with the specified message "Invalid request type".
expect(apiReq.request.method).toBe("GET", "Invalid request type");
//Expects that the response type of the HTTP request is 'json'. If it is not, the test will fail with the specified message "Invalid response type".
expect(apiReq.request.responseType).toBe('json', "Invalid response type");
//Simulates the response from the server by flushing the specified data to the observable. In this case, it's simulating a successful response with the payload being the values of the COURSES object.
apiReq.flush({payload: Object.values(COURSES)});
});flush()
The
flushmethod is part of theHttpTestingControllerin Angular's testing module, specifically within the@angular/common/http/testingpackage. It is used to simulate the response of an HTTP request and complete the associated observable.
Here's an explanation of how the flush method works:
apiReq.flush({ payload: Object.values(COURSES) });apiReq: This is an instance ofTestRequestreturned byhttpTestingController.expectOne('/api/courses'). It represents an expected HTTP request.flush: This method is used to simulate the response of an HTTP request. It completes the request by providing a response object.
When apiReq.flush({ payload: Object.values(COURSES) }); is called, it simulates the server responding to the HTTP request. The observable returned by the HttpClient service in your Angular code will now emit the specified response object, allowing the code that subscribed to the observable to continue its execution.
This mechanism is crucial for testing because it allows you to control and mock the HTTP responses during testing without making actual network requests. It isolates your tests from external dependencies, making them more predictable and focused on specific code behaviors.
Verify()
One important thing which we keep in mind while making mock http calls in our test cases is that we need to verify if all our API calls are done and there is not unintended API call is trigger, is by using the
verify()method.
verify()method is used to assert whether there are any outstanding HTTP requests that were not expected to be made. This is important in the cleanup phase of your tests to ensure that all expected requests were made and no unexpected requests occurred.
Here's an example, how to use flush method:
afterEach(() => {
httpTestingController.verify();
});Test data modification function
it("Should save Course/Edit existing Course by ID", () => {
coursesService
.saveCourse(12, {
titles: {
description: "Update Course Description",
},
lessonsCount: 5,
})
.subscribe((course) => {
//Ensures that the course variable is truthy, meaning it is not null or undefined. If it is falsy, the test will fail with the specified message "No courses returned".
expect(course).toBeTruthy("No updated course returned");
//Expects that the description of the course updated to "Update Course Description". If not, the test will fail with the specified message "Invalid course description".
expect(course.titles.description).toBe("Update Course Description");
//Expects that the lessonsCount should update to 5
expect(course.lessonsCount).toEqual(5);
});
const apiReq = httpTestingController.expectOne("/api/courses/12");
//Expects that the HTTP request is not cancelled. If it is cancelled, the test will fail.
expect(apiReq.cancelled).toBeFalsy();
//Expects that the HTTP request method is 'PUT'. If it is not, the test will fail with the specified message "Invalid request type".
expect(apiReq.request.method).toBe("PUT");
//Expects that the HTTP request body having the updated description under titles. If it is not, the test will fail.
expect(apiReq.request.body.titles.description).toEqual(
"Update Course Description"
);
//Expects that the HTTP request body having the updated lessons count, which is 5. If it is not, the test will fail.
expect(apiReq.request.body.lessonsCount).toEqual(5);
//Simulates the response from the server by flushing the specified data to the observable. In this case, it's simulating a successful response with the updated COURSE details.
apiReq.flush({
...COURSES[12],
titles: {
description: "Update Course Description",
},
lessonsCount: 5,
});
});This test case verifies that the saveCourse method makes a correct HTTP request to update an existing course and processes the response appropriately.
Testing API error handling
Here are few types of errors which we can test in case of HTTP calls: 1. Status code of HTTP request to be 404 2. Status code of HTTP request to be 500 3. Network Error 4. Custom Error Response
For a particular status code (404, 500 or any other), we can use the following code:
it("Should have failed with error code 404", () => {
coursesService
.saveCourse(50, {
titles: {
description: "Update Course Description",
},
lessonsCount: 5,
})
.subscribe(
() => fail("Expected to be failed but got an successfull response!"),
(error: HttpErrorResponse) => {
expect(error.status).toBe(404);
}
);
const apiReq = httpTestingController.expectOne("/api/courses/50");
expect(apiReq.request.method).toBe("PUT", , "Invalid request type");
apiReq.flush(null, { status: 404, statusText: "Not Found" });
});This test checks if the saveCourse method handles a 404 Internal Server Error response appropriately, for other status codes you can use similar kind of code.
For a network error, we can use the following code:
it("should handle network error", () => {
coursesService.findCourseById(50).subscribe(
() => fail("Expected an error, but got a successful response"),
(error) => {
expect(error).toBeTruthy("No error received");
// Additional error handling checks if needed
}
);
const apiReq = httpTestingController.expectOne("/api/courses/50");
expect(apiReq.request.method).toBe("GET", "Invalid request type");
apiReq.error(new ErrorEvent("Network error"), {
status: 0,
statusText: "Network error",
});
});This test checks if the findCourseById method handles a network error appropriately or not.
For a custom error message, we can use the following code:
it("should handle custom error response", () => {
coursesService.findCourseById(50).subscribe(
() => fail("Expected an error, but got a successful response"),
(error) => {
expect(error.error).toEqual({ message: "Custom error message" });
// Additional error handling checks if needed
}
);
const apiReq = httpTestingController.expectOne("/api/courses/50");
expect(apiReq.request.method).toBe("GET", "Invalid request type");
apiReq.flush({ message: "Custom error message" }, { status: 400, statusText: "Bad Request" });
});This test checks if the findCourseById method handles a custom error message appropriately or not.
Please find gist for the complete testing code from here: https://gist.github.com/gurindernarang/20a11e74c34f498a57cb75ecc07a44aa
In summary, unit testing with mocked HTTP responses is essential for creating robust, isolated, and predictable tests that focus on the behavior of your code rather than external services. It facilitates faster and more efficient testing while providing the flexibility to simulate various scenarios and conditions.