arrow-rightgithublinkedinscroll-to-topzig-zag

Unit testing Angular service with HttpClient

Last Updated On

Update (02/11/2019) - Addressed few testing issues in the examples

Table of contents

Foreword

Automated testing is one of the most important concepts that stand out Angular from the crowd of front-end Frameworks. Preconfigured test environment and out of the box working boilerplates make it even easier, to begin with. However, that's doesn't cover a case when your app needs to communicate with a server and that's what this article will be about. The main goal here is to show how to write unit tests for HttpClient - library that is used for "communication with backend" in Angular.

Testing strategy overview

Let's take a look at Angular specific details. Here, I want to highlight some concepts that will be used when testing Angular service:

1. Use HttpClient only inside Angular's service.

In other words not inside the component class. We want to isolate our "Data retrieving logic" from the view and be able to modify it without affecting the UI. Example: you have weather forecast app. Current weather provider suddenly stops working. We need to refactor our service to use the new data provider. So we will change our logic to be able to get data from a new server but keeping methods that are used by UI untouched. In other words, UI won't know about provider changes.

2. Mock Unit Tests with stubs.

To explain this concept I want you to imagine the following situation: Again, we work on a weather app, but in this case on a really huge and popular (like "Weather Underground"). The task is to test a function getWeatherInFahrenheit() which returns a value in a specific format. For example 70°F.

I wrote a unit test that goes to a server, gets the value and compares it to my expected value, e.g. 70°F. Let's assume that test passed. I added it to the common test suite and closed my task. Here'are few problems though:

  1. From now on this test will be run by every developer since it was added to the main test suite. If there're hundreds or more developers we will create unnecessary load on a data provider by asking data just for testing purposes.
  2. The main idea of the unit test is broken. Unit test supposes to test (as a name applies) individual unit. In this case, we added a dependency from a server. This is usually one of the problems that integration is solving - testing the communication between individual pieces inside the whole system.

Solution: We will introduce the concept of "Mock" (or stub). We will emulate response from a server by creating our own *.json file that will be the same as the response from a server.

Karma configuration

If the project you're working on has been initialized with Angular-CLI it will have karma.conf.js which is responsible for unit testing configuration. The content of that file contains the following:

// Karma configuration file, see link for more information
// https://karma-runner.github.io/1.0/config/configuration-file.html

module.exports = function (config) {
  config.set({
    ...
    browsers: ['Chrome'],
    ...
  });
};

This configuration requires to run a real browser and by default it's Chrome (since we have a line browsers: ['Chrome']). That might cause a problem if an application will be deployed to a server (since there's no GUI browser on a server). In addition to that, it might slow down the process of running unit tests.

The solution is to use "Headless" or command-line interface browser. It has the same functionality as a regular web browser, but doesn't require UI and can be executed in a terminal. There're few of these and the most popular solution was to use PhantomJS. However, nowadays there's more modern solution which is ChromeHeadless.

In order to use it we need to change only one line:

Replace browsers: ['Chrome'] with browsers: ['ChromeHeadless']. No extra packages needed.

First step - testing basic class

In this situation, it won't be even a service - just simple Typescript class. There's no problem with testing a class - just create an instance of it and call a method. Let's see what I mean by using a simple User class with one method:

export class User {
  private email: string;
  private password: string;

  constructor(email: string, password: string) {
    this.email = email;
    this.password = password;
  }

  public toJson() {
    return {
      email: this.email,
      password: this.password
    };
  }
}

Unit test for this class will look like:

import { User } from './user';
describe('User', () => {
  
  let user = null;

  /**
  * beforeEach will create fresh copy of User object
  * before each unit test. We also can define User object once 
  * and run unit tests against it, but in general it's a good practice
  * to work on a new instance of an object to avoid modifications from
  * the previous test run.
  */
  beforeEach(() => {
    user = new User('john@doe', 'johnspassword');
  });

  it('should be initialized', () => {
    expect(user).toBeTruthy();
  });
  it('should be serialized to Json properly', () => {
    const jsonPropertiesActual = Object.keys(user.toJson());
    const jsonPropertiesExpected = [
      'email',
      'password'
    ];
    expect(jsonPropertiesActual).toEqual(jsonPropertiesExpected);
  });
});

Second step - testing real Angular service

Here's an example of Angular service:

import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';

@Injectable()
export class AuthService {
  constructor(private http: HttpClient) {}
}

Looks very similar to the first step. However, there is a pitfall here. Inside the constructor, there is an instance of httpClient which is created and fully managed by Angular. Behind the scenes, Angular makes sure that we will have only one instance of HttpClient across the whole application. As a result, we can not simply create a new instance of HttpClient and pass it to our service:

//...
//We can not do that, there will be an error
beforeEach(() => {
  const httpClient = new HttpClient();
  const authService = new AuthService(httpClient);
});

//...

As a solution we will "inject" it in Angular way. Here's how we do it:

import { TestBed, inject } from '@angular/core/testing';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { AuthService } from './auth.service';

describe('AuthService', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [AuthService]
    });
  });

  it('should be initialized', inject([AuthService], (authService: AuthService) => {
    expect(authService).toBeTruthy();
  }));
});

There're few new utilities here: TestBed, inject and HttpClientTestingModule.

  1. HttpClientTestingModule is analog of HttpClientModule, but for testing purposes.
  2. inject is Angular utility function that injects services into the test function. It takes two params: an array of services that we want to inject and instances of those services.
  3. TestBed.configureTestingModule is the same as @NgModule, but for test initialization.

Third Step - Add login to the service and write unit test for that

Let's add login method to our service. Here's how a final code will look:

import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { User } from '../models/user';
import { Observable } from 'rxjs/Observable';

@Injectable()
export class AuthService {
  private apiUrl = 'https://example.com/login';
  constructor(private http: HttpClient) {}

  public onLogin(user: User): Observable<Object> {
    return this.http.post(this.apiUrl, user);
  }
}

And here's a complete unit test for that:

import { TestBed, inject } from '@angular/core/testing';
import {
  HttpClientTestingModule,
  HttpTestingController
} from '@angular/common/http/testing';
import { AuthService } from './auth.service';
import { User } from '../models/user';

describe('AuthService', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [AuthService]
    });
  });

  it(
    'should be initialized',
    inject([AuthService], (authService: AuthService) => {
      expect(authService).toBeTruthy();
    })
  );

  it(
    'should perform login correctly',
    fakeAsync(
      inject(
        [AuthService, HttpTestingController],
        (authService: AuthService, backend: HttpTestingController) => {

          // Set up
          const url = 'https://example.com/login';
          const responseObject = {
            success: true,
            message: 'login was successful'
          };
          const user = new User('test@example.com', 'testpassword');
          let response = null;
          // End Setup

          authService.onLogin(user).subscribe(
            (receivedResponse: any) => {
              response = receivedResponse;
            },
            (error: any) => {}
          );

          const requestWrapper = backend.expectOne({url: 'https://example.com/login'});
          requestWrapper.flush(responseObject);

          tick();

          expect(requestWrapper.request.method).toEqual('POST');
          expect(response.body).toEqual(responseObject);
          expect(response.status).toBe(200);
        }
      )
    )
  );

  it(
    'should fail login correctly',
    fakeAsync(
      inject(
        [AuthService, HttpTestingController],
        (authService: AuthService, backend: HttpTestingController) => {

          // Set up
          const url = 'https://example.com/login';
          const responseObject = {
            success: false,
            message: 'email and password combination is wrong'
          };
          const user = new User('test@example.com', 'wrongPassword');
          let response = null;
          // End Setup

          authService.onLogin(user).subscribe(
            (receivedResponse: any) => {
              response = receivedResponse;
            },
            (error: any) => {}
          );

          const requestWrapper = backend.expectOne({url: 'https://example.com/login'});
          requestWrapper.flush(responseObject);

          tick();

          expect(requestWrapper.request.method).toEqual('POST');
          expect(response.body).toEqual(responseObject);
          expect(response.status).toBe(200);
        }
      )
    )
  );
});

There are multiple things going on here. Since our logic contains asynchronous call we need to tell unit test to wait until it finishes to make some assertions after. It can be achieved by using fakeAsync which works together with tick(). At a high level, tick() tells: "wait all asynchronous code to be finished and then continue".

You can also find a new concept of HttpTestingController which is named as a backend. Basically, it watches the URL which will be called, intercepts it and returns a "fake" response. From the example above you can see the API where we pass an object that contains URL to intercept and then flush it with "fake" response. In addition to expectOne, there're other methods like match, expectNone and verify. You can find more information about them in the official documentation.

Live demo

Here's the live example of this demo. Combination test@example.com, testpassword is correct and any other combination is wrong.

Conclusion

Being able to verify every little piece of your functionality is very important when adding new features, enhancing your current functionality or simply refactoring your code. In this article, we covered what testing is, how to write a basic test for Angular and how to test service with HttpClient as a dependency injection. I would recommend playing on your own, try different technics and move to the next level by using TDD.

As always, this article is aimed to give you a basic idea how to quickly start when testing HttpClient for Angular. For more details, please take a look at the official documentation.