Update (02/11/2019) - Addressed few testing issues in the examples
Table of contents
- Foreword
- Testing strategy overview
- Karma configuration
- First step - testing basic class
- Second step - testing real Angular service
- Third Step - Add login to the service and write unit test for that
- Live demo
- Conclusion
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:
- 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.
- 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 {
: this.email,
email: this.password
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
.
HttpClientTestingModule
is analog of HttpClientModule, but for testing purposes.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.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.