How to Unit Test an HTTP Service in Angular
September 20, 2021
|
āāā views(Updated on April 21, 2022)
September 20, 2021
|
āāā views(Updated on April 21, 2022)
We now understand the importance of testing in isolation I want to shift our focus to testing an HTTP Service. By the time we wrap up this addition to the series, not only will you understand how to write valuable tests, but you'll also understand what to test - something I feel a lot of newcomers to unit testing struggle to wrap their minds around.
For the purposes of this article, I've created a new Angular application and bootstrapped a json-server into the project so we can make API requests and compliment our learning process. By default, this API is running on localhost:3000.
If you'd like to follow along, feel free to clone down this repo before continuing! I've created a starting branch that has everything you need to follow along!
When you run ng test in a new Angular project, the Karma report will be opened in a new Chrome tab. I prefer to have my test results shown in the terminal. To make this change, alter the browsers property in your karma.config.js file.
JAVASCRIPTmodule.exports = function(config) {config.set({...browsers: ['ChomeHeadless'],});}
I have created a very simplistic HTTP service with all of the CRUD operations. Take a look below.
TYPESCRIPT@Injectable({providedIn: 'root',})export class BooksService {url = 'localhost:3000/';httpOptions = {headers: new HttpHeaders({ 'Content-Type': 'application/json' }),};constructor(private http: HttpClient) {}getAllBooks(): Observable<Book[]> {return this.http.get<Book[]>(`${this.url}/books`).pipe(catchError(this.handleError<Book[]>('getAllBooks', [])));}getBookById(id: number): Observable<Book> {return this.http.get<Book>(`${this.url}/books/${id}`).pipe(catchError(this.handleError<Book>(`getBookById id=${id}`)));}updateBook(book: Book): Observable<any> {return this.http.put(`${this.url}/books`, book, this.httpOptions).pipe(catchError(this.handleError<any>(`updateBook`)));}addBook(book: Book): Observable<Book> {return this.http.post<Book>(`${this.url}/books`, book, this.httpOptions).pipe(catchError(this.handleError<Book>(`addBook`)));}deleteBook(book: Book): Observable<Book> {return this.http.delete<Book>(`${this.url}/books/${book.id}`, this.httpOptions).pipe(catchError(this.handleError<Book>(`deleteBook`)));}private handleError<T>(operation = 'operation', result?: T) {return (error: any): Observable<T> => {console.error(`${operation} failed: ${error.message}`);return of(result as T);};}}
If you feel uncomfortable with any of these functions and what they are doing or the various operators in play, read the official Angular documentation about creating HTTP services.
With this basic Service in play, now is a good time to address the elephant in the room. What should you test in this class? There's a total of five functions, each making an API call to our json-server backend.
All functions we create, whether that's in a Component or Service, should have valuable supporting test cases.
To help identify what to test, let's briefly turn our attention to a simple metaphor from a previous article I wrote called The Gumball Machine: How To Quickly Identify Unit Test Cases.
How does a gumball machine work? There are three major events:
Think of functions as a gumball machine and follow the three steps:
I find it's helpful to scan the function and write down the various logic branches and the possible values that can be returned. These notes become an outline for writing unit tests for that function.
Take a second and give the Service above a once-over. Scan through the functions and determine the input and output. Is there anything else that would be beneficial for us to check? Create a testing outline and then continue reading.
Done?
Here's what I came up with:
Running the tests at this point produces an error. Can you guess why?
BASHChrome Headless 92.0.4515.159 (Mac OS 10.15.7) BooksService should be created FAILEDNullInjectorError: R3InjectorError(DynamicTestModule)[BooksService -> HttpClient -> HttpClient]:NullInjectorError: No provider for HttpClient!error properties: Object({ ngTempTokenPath: null, ngTokenPath: [ 'BooksService', 'HttpClient', 'HttpClient' ] })...
The error message actually gives us a hint. We aren't testing this Service in isolation - is has an injected dependency: the HTTP Client. In order for the default test to pass in the Service, we need to bring in the HttpClientTestingModule - a module that provides all of the tools that we need to properly test Angular HTTP Services.
TYPESCRIPTimport { HttpClientTestingModule } from '@angular/common/http/testing';...beforeEach(() => {TestBed.configureTestingModule({imports: [HttpClientTestingModule]});service = TestBed.inject(BooksService);});
The test should pass now. Great!
When writing unit tests, I like to follow the Arrange-Act-Assert (the 3 A's) pattern to help structure my test cases.
Let's focus on the first piece of code in the HTTP service - the getAllBooks function. It doesn't take any function arguments and is expected to return an array of Books.
With this in mind, let's create a new test and add the following test logic:
TYPESCRIPTimport {HttpClientTestingModule,HttpTestingController,} from '@angular/common/http/testing';import { mockBookArray } from 'src/mocks/mockBooks';describe('BooksService', () => {let service: BooksService;let httpController: HttpTestingController;let url = 'localhost:3000/';beforeEach(() => {TestBed.configureTestingModule({imports: [HttpClientTestingModule],});service = TestBed.inject(BooksService);httpController = TestBed.inject(HttpTestingController);});it('should call getAllBooks and return an array of Books', () => {// 1service.getAllBooks().subscribe((res) => {//2expect(res).toEqual(mockBookArray);});//3const req = httpController.expectOne({method: 'GET',url: `${url}/books`,});//4req.flush(mockBookArray);});}
This may look like a lot and be confusing, so let me break it down.
At this point, if you run the test, you should get a passing checkmark.
This function is similar to the first. Can you come up with test criteria?
Here's how I'm testing this function:
TYPESCRIPTimport { mockBook1, mockBookArray } from 'src/mocks/mockBooks';...it('should call getBookById and return the appropriate Book', () => {// Arrangeconst id = '1';// Actservice.getBookById(id).subscribe((data) => {// Assertexpect(data).toEqual(mockBook1);});const req = httpController.expectOne({method: 'GET',url: `${url}/books/${id}`,});req.flush(mockBook1);});
This test allows you to see a bit more of the Arrange-Act-Assert pattern. Due to the nature of the code under test, we know that the function requires an ID value to be passed. We control this from the test-side by declaring an id variable, setting the value to '1', and passing it to the getBookById function.
Everything else is familiar - we still check that the request method is GET and that the appropriate URL is being hit. We also send back a mock Book via the flush method so that our assertion kicks off inside of the subscribe block.
Now let's look at the updateBook function. The same patterns apply here, but the request method is different. Don't let that scare you! Take note of what argument(s) the function requires, and what the expected output is, then write the test.
TYPESCRIPTit('should call updateBook and return the updated book from the API', () => {const updatedBook: Book = {id: '1',title: 'New title',author: 'Author 1',};service.updateBook(mockBook1).subscribe((data) => {expect(data).toEqual(updatedBook);});const req = httpController.expectOne({method: 'PUT',url: `${url}/books`,});req.flush(updatedBook);});
Once you know the pattern, testing HTTP Services in Angular isn't that difficult.
Try testing the remaining functions in the Service class. Can you do it?
Feel free to check the completed_tests branch of my GitHub repository and use it as a reference if you get stuck!
Thanks for reading! If you enjoyed this article and found it helpful, consider reading my other articles and subscribing to my newsletter below!
š
ā¤ļø
š
š
Share this article
A periodic update about my life, recent blog posts, how-tos, and discoveries.
No spam - unsubscribe at any time!