How to Unit Test an HTTP Service in Angular
Braydon Coyer / September 20, 2021
10 minute read
•
--- viewsWe now understand the importance of testing in isolation and 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 complement 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.
1module.exports = function(config) {2 config.set({3 ...4 browsers: ['ChomeHeadless'],5 });6}
I have created a very simplistic HTTP service with all of the CRUD operations. Take a look below.
1@Injectable({2 providedIn: 'root'3})4export class BooksService {5 url = 'localhost:3000/';6
7 httpOptions = {8 headers: new HttpHeaders({ 'Content-Type': 'application/json' })9 };10
11 constructor(private http: HttpClient) {}12
13 getAllBooks(): Observable<Book[]> {14 return this.http15 .get<Book[]>(`${this.url}/books`)16 .pipe(catchError(this.handleError<Book[]>('getAllBooks', [])));17 }18
19 getBookById(id: number): Observable<Book> {20 return this.http21 .get<Book>(`${this.url}/books/${id}`)22 .pipe(catchError(this.handleError<Book>(`getBookById id=${id}`)));23 }24
25 updateBook(book: Book): Observable<any> {26 return this.http27 .put(`${this.url}/books`, book, this.httpOptions)28 .pipe(catchError(this.handleError<any>(`updateBook`)));29 }30
31 addBook(book: Book): Observable<Book> {32 return this.http33 .post<Book>(`${this.url}/books`, book, this.httpOptions)34 .pipe(catchError(this.handleError<Book>(`addBook`)));35 }36
37 deleteBook(book: Book): Observable<Book> {38 return this.http39 .delete<Book>(`${this.url}/books/${book.id}`, this.httpOptions)40 .pipe(catchError(this.handleError<Book>(`deleteBook`)));41 }42
43 private handleError<T>(operation = 'operation', result?: T) {44 return (error: any): Observable<T> => {45 console.error(`${operation} failed: ${error.message}`);46
47 return of(result as T);48 };49 }50}
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 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:
- Put a quarter in the machine
- Turn the handle
- A gumball comes rolling out
Think of functions as a gumball machine and follow the three steps:
- Put the quarter in the machine (pass arguments to the function, if necessary)
- Turn the handle (execute the code under test — the function itself)
- A gumball comes rolling out (verify the behavior - the function returns the expected data)
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:
- Check that the functions return appropriate data (array of Books or a single Book)
- Check that the expected API endpoint was called with the appropriate request method
- If an error occurs, check to make sure that the
handleError
function was called with the appropriate argument(s). NOTE: I won't be focussing on this test case in this article.
Running the tests at this point produces an error. Can you guess why?
1Chrome Headless 92.0.4515.159 (Mac OS 10.15.7) BooksService should be created FAILED2 NullInjectorError: R3InjectorError(DynamicTestModule)[BooksService -> HttpClient -> HttpClient]:3 NullInjectorError: No provider for HttpClient!4 error properties: Object({ ngTempTokenPath: null, ngTokenPath: [ 'BooksService', 'HttpClient', 'HttpClient' ] })5...
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.
1import { HttpClientTestingModule } from '@angular/common/http/testing';2...3
4beforeEach(() => {5 TestBed.configureTestingModule({6 imports: [HttpClientTestingModule]7 });8 service = TestBed.inject(BooksService);9 });
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.
- Arrange - set up the test case. Does the test require any special preparation? Use this step to get the code under test (the Service function) in a place where we can make our assertions. There will be times when there isn't anything to Arrange. That's fine - continue on to the next step.
- Act - execute the code under test. In order for us to determine the expected behavior of software, we need to run the code under test. Pass any necessary arguments to the code under test in order to achieve the expected behavior.
- Assert - verify expected outcomes. This is the step that actually controls whether your test passes or fails.
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:
1import {2 HttpClientTestingModule,3 HttpTestingController,4} from '@angular/common/http/testing';5
6import { mockBookArray } from 'src/mocks/mockBooks';7
8describe('BooksService', () => {9 let service: BooksService;10 let httpController: HttpTestingController;11
12 let url = 'localhost:3000/';13
14 beforeEach(() => {15 TestBed.configureTestingModule({16 imports: [HttpClientTestingModule],17 });18 service = TestBed.inject(BooksService);19 httpController = TestBed.inject(HttpTestingController);20 });21
22 it('should call getAllBooks and return an array of Books', () => {23
24 // 125 service.getAllBooks().subscribe((res) => {26 //227 expect(res).toEqual(mockBookArray);28 });29
30 //331 const req = httpController.expectOne({32 method: 'GET',33 url: `${url}/books`,34 });35
36 //437 req.flush(mockBookArray);38 });39}
This may look like a lot and be confusing, so let me break it down.
- I call the code under test - the
getAllBooks
function. This is part of the Act step in the Arrange-Act-Assert pattern. - I make sure the data coming back from the function is an array of Books, which I've mocked out and brought into this test file. This satisfies the Assert step in the Arrange-Act-Assert pattern. You may be thinking that this looks funny; why do we need to subscribe to the
getAllBooks
function? The function returns an Observable, so the only way to check the data that is being returned is to subscribe to the Observable and make the assertion inside. - We set up and utilize the
HttpTestingController
for multiple reasons, but here we're using it to specify the URL that we expect the Service function to hit, as well as the request method to be used. - We also use the
HttpTestingController
to flush (send) data through the stream. At first glance this sort of seems to go against the normal testing pattern where you'd specify the data to be returned before the assertion statement. However, because we must subscribe to thegetAllBooks
function, we flush the data after we're listening for that Observable to emit the value.
To be even more clear, when the flush statement is executed, it sends the mockBookArray
data through the stream, the subscribe block resolves and our assertion then takes place.
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:
1import { mockBook1, mockBookArray } from 'src/mocks/mockBooks';2...3it('should call getBookById and return the appropriate Book', () => {4 // Arrange5 const id = '1';6
7 // Act8 service.getBookById(id).subscribe((data) => {9
10 // Assert11 expect(data).toEqual(mockBook1);12 });13
14 const req = httpController.expectOne({15 method: 'GET',16 url: `${url}/books/${id}`,17 });18
19 req.flush(mockBook1);20});
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, then write the test.
1it('should call updateBook and return the updated book from the API', () => {2 const updatedBook: Book = {3 id: '1',4 title: 'New title',5 author: 'Author 1'6 };7
8 service.updateBook(mockBook1).subscribe((data) => {9 expect(data).toEqual(updatedBook);10 });11
12 const req = httpController.expectOne({13 method: 'PUT',14 url: `${url}/books`15 });16
17 req.flush(updatedBook);18});
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!
Articles delivered to your inbox!
A periodic update about my life, recent blog posts, how-tos, and discoveries.
As a thank you, I'll also send you a FREE CSS tutorial!
No spam - unsubscribe at any time!