This article was originally published on my personal blog

With the concept of isolated testing established, we now turn our attention to HTTP services. By the end of this segment, you'll not only know how to write meaningful tests, but also gain clarity on what exactly deserves your testing effort—a point that often trips up those new to unit testing.

Readers who haven't gone through the earlier parts of this series are encouraged to review those foundational topics before moving forward here.

Getting Familiar with the Test Environment

To accompany this guide, I've set up a fresh Angular application and integrated a json-server as a mock backend, making our API calls more realistic. By default, this server listens on localhost:3000.

If you prefer to code along, go ahead and clone this repository. Make sure to check out the starting branch, which contains all the necessary code to get going.

Adjusting karma.config to Use ChromeHeadless

Upon running ng test in a standard Angular project, Karma launches a new Chrome window to display the test report. I personally find it more convenient to see the results directly in the terminal. This can be achieved by modifying the browsers setting within the karma.config.js file.

module.exports = function(config) {
    config.set({
    ...
    browsers: ['ChomeHeadless'],
    });
}
Enter fullscreen mode Exit fullscreen mode

The HTTP Service Under Test

Below is a straightforward HTTP service that implements all standard CRUD operations. Take a moment to examine it.

@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);
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

If any of these operations or the RxJS operators used are unclear, the official Angular documentation on creating HTTP services is a great resource.

The URL is hardcoded here for simplicity, but in a real project, you would typically pull this from an environment configuration.

Deciding on the Test Cases

With our service defined, it's time to tackle the main question: what parts of this class require testing? We have five methods, each hitting our json-server API.

Any function we write—whether in a component or a service—should have corresponding tests to validate its behavior.

Let's revisit a simple framework from an earlier post, The Gumball Machine: How To Quickly Identify Unit Test Cases, to help us pick out the right scenarios.

Applying the Gumball Machine Model

The workings of a gumball machine can be broken down into three key stages:

  1. Insert a coin
  2. Rotate the lever
  3. Receive a gumball

https://images.unsplash.com/photo-1627173346975-58de4e5ec98d?ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&ixlib=rb-1.2.1&auto=format&fit=crop&w=1950&q=80

You can apply this same logic to any function by walking through these three steps:

  1. Insert the coin (supply the function with any required inputs)
  2. Rotate the lever (invoke the function you're evaluating)
  3. Receive the gumball (confirm that the output matches your expectations)

A practical approach is to skim through your function, jotting down the different conditional paths and all possible return values. These notes will serve as your guide when you begin writing the unit tests.

Pinpointing Test Scenarios for an Angular HTTP Service

Pause for a moment and review the service provided earlier. Look over each method, noting what goes in and what comes out. Are there any other aspects worth verifying? Sketch out your own test plan, and then come back to compare.

Ready to see what I came up with?

Here's my suggested checklist:

  • Verify that each function delivers the expected data type (an array of Books for the list, or a single Book for individual fetches)
  • Confirm that the correct API endpoint is accessed using the appropriate HTTP method
  • When an error is thrown, ensure the handleError method is invoked with the proper arguments. Note: Testing this specific error-handling path is out of scope for this current article.

Integrating HttpClientTestingModule into the Test Suite

If you run the default tests at this stage, you'll likely encounter an error. Can you deduce the reason for this failure?

Chrome Headless 92.0.4515.159 (Mac OS 10.15.7) BooksService should be created FAILED
        NullInjectorError: R3InjectorError(DynamicTestModule)[BooksService -> HttpClient -> HttpClient]: 
          NullInjectorError: No provider for HttpClient!
        error properties: Object({ ngTempTokenPath: null, ngTokenPath: [ 'BooksService', 'HttpClient', 'HttpClient' ] })
...
Enter fullscreen mode Exit fullscreen mode

The error output points us in the right direction. Our service isn't being tested in true isolation; it relies on an injected dependency—the HTTP Client. To get our initial test passing, we need to import the HttpClientTestingModule. This module equips us with everything required for properly testing Angular HTTP services.

import { HttpClientTestingModule } from '@angular/common/http/testing';
...

beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule]
    });
    service = TestBed.inject(BooksService);
  });
Enter fullscreen mode Exit fullscreen mode

After this update, the test should pass. Excellent!

Alternative approaches exist for testing HTTP services without the HTTPClientTestingModule, such as mocking dependencies manually. However, for the sake of simplicity, this tutorial will stick with the built-in module from Angular.

The Arrange-Act-Assert Testing Pattern

I tend to structure my unit tests around the Arrange-Act-Assert (or "3 A's") pattern for better organization and readability.

  1. Arrange - get everything ready. This is where you set up any prerequisites or state needed before you can invoke the function you're testing. In some cases, no special setup is required, and you can move on.
  2. Act - run the code. This step involves actually calling the method under test, providing any necessary parameters to trigger the behavior you're expecting.
  3. Assert - check the results. Here, you verify that the output or side effects align with your expectations. This is the decisive step that confirms whether your test passes or falls.

Creating a Unit Test for the getAllBooks Method

Let's begin with the first method in our HTTP service — the getAllBooks function. This method takes no parameters and is designed to return an array of Books.

Given that, let's set up a new test case with the following logic:

import {
  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', () => {

            // 1
          service.getAllBooks().subscribe((res) => {
                //2
          expect(res).toEqual(mockBookArray);
        });

            //3
        const req = httpController.expectOne({
          method: 'GET',
          url: `${url}/books`,
        });

            //4
        req.flush(mockBookArray);
      });
}

Enter fullscreen mode Exit fullscreen mode

This block might feel overwhelming at first, so let's walk through what's happening.

  1. The code under test — the getAllBooks function — is being invoked here. This corresponds to the Act phase of the Arrange-Act-Assert pattern.

  2. I'm verifying the data returned by the function is an array of Books, which has been mocked and imported into this test file. This covers the Assert phase of the Arrange-Act-Assert pattern. You might wonder why subscribing to the getAllBooks function is necessary. Since the function returns an Observable, the only way to inspect the emitted data is to subscribe to that Observable and perform the assertion within the subscription callback.

  3. We configure and apply the HttpTestingController for several purposes. In this case, it's used to specify the expected URL the Service method should target, along with the HTTP request method being used.

  4. The HttpTestingController is also used to flush — that is, send — data through the stream. At first, this may appear to deviate from the standard testing flow where you'd define the expected return value before writing the assertion. However, because we need to subscribe to getAllBooks, the data is flushed only after we're already listening for the Observable to emit its value.

To clarify: when the flush statement runs, it pushes the mockBookArray data through the stream. At that point, the subscribe block fires, and our assertion gets executed.

Once you run the test, you should see a passing checkmark.

If you want access to the mock data used in these examples, head over to my GitHub repo on the completed_test branch.

Writing a Unit Test for the getBookById Method

This method bears a close resemblance to the previous one. Can you think of what the test criteria should be?

Here's the approach I took to test it:

import { mockBook1, mockBookArray } from 'src/mocks/mockBooks';
...
it('should call getBookById and return the appropriate Book', () => {
        // Arrange
    const id = '1';

        // Act
    service.getBookById(id).subscribe((data) => {

            // Assert
      expect(data).toEqual(mockBook1);
    });

    const req = httpController.expectOne({
      method: 'GET',
      url: `${url}/books/${id}`,
    });

    req.flush(mockBook1);
});
Enter fullscreen mode Exit fullscreen mode

This test gives you a clearer view of the Arrange-Act-Assert pattern in action. Given how the code under test is structured, we know the method expects an ID value as its argument. From the test side, we manage this by declaring an id variable, assigning it the value '1', and then passing it into getBookById.

Everything else follows the same pattern — we verify the request method is GET and that the correct URL is being called. We also deliver a mock Book through the flush method, which triggers the assertion inside the subscribe block.

Writing a Unit Test for the updateBook Method

Next up is the updateBook function. The same testing principles apply here, though the HTTP request method differs. Don't let that throw you off! Pay attention to what arguments the function requires and what the expected outcome should be, then write the test accordingly.

it('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);
});
Enter fullscreen mode Exit fullscreen mode

Wrapping Up

Once you've internalized the pattern, testing HTTP Services in Angular becomes quite manageable.

Go ahead and try writing tests for the remaining methods in the Service class. Think you can handle it?

If you'd like a reference, feel free to look at the completed_tests branch of my GitHub repository if you find yourself stuck!

Thanks for reading! If you found this article valuable, be sure to check out my other posts and sign up for my newsletter below.