Unit testing Angular applications with Jest
July 26, 2017
Let’s discuss about unit testing in Angular, this will be just an introduction on Angular unit testing and what are the benefits of adopting Jest as test runner and suite for your execution.
What?
Jest is an integrated testing solution written by Facebook and famous especially in the React world.
The key features of Jest are:
- Easy setup
- Instant feedback
- Powerful mocking
- Works with typescript
- Snapshot testing
Easy setup meaning that you need almost zero configuration to get started.
Instant feedback because he will run only the test files related to the changes.
Powerful mocking through easy to use mock functions
Why?
The first reason why you want to start using jest is speed.
Unit test should run fast
Comparing brand new application crated with the @angular/cli
karma-chrome: 14.911s
jest: 4.970s
That’s 8.606 seconds difference between the two runs, karma-chrome are taking more than double the time to execute just 1 suite and 3 tests.
I’m including PhantomJS in these comparison even if its not supported anymore, mainly because its probably the fastest option for running tests in a CI environment ( Jenkins, Travis )
Jest doesn’t need an actual browser ( headless or not ) to run the test ( there are some drawbacks ). If you are looking for a replacement in Continuous Integration environments for PhantomJS you can quickly switch to Jest without the need of any configuration on your CI.
It is based upon Jasmine which is probably the default framework for Angular applications and its included in the CLI.
How?
The first step is to install jest on your new project:
$ yarn add -D @types/jest jest jest-preset-angular
Installing the types necessary for typescript compiler, the framework itself and in the jest-preset-angular
which contains a configuration for Angular project.
Next step, modify the package.json
:
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"lint": "ng lint",
"e2e": "ng e2e",
"test": "jest",
"test:watch": "jest --watch"
},
"jest": {
"preset": "jest-preset-angular",
"setupTestFrameworkScriptFile": "<rootDir>/src/jest.ts"
}
I’m changing the npm scripts that will execute jest framework and adding the only configuration ( almost zero config ) that we need for the project.
I’m requiring "setupTestFrameworkScriptFile": "<rootDir>/src/jest.ts"
we need then to create this file that Jest will use to startup inside the src folder of the project.
import 'jest-preset-angular';
import './jest-global-mocks';
The last step, since Jest doesn’t run a real browser but its based upon jsdom, its to provide mocks for browser specific API like localStorage
etc:
const mock = () => {
let storage = {};
return {
getItem: key => (key in storage ? storage[key] : null),
setItem: (key, value) => (storage[key] = value || ''),
removeItem: key => delete storage[key],
clear: () => (storage = {})
};
};
Object.defineProperty(window, 'localStorage', { value: mock() });
Object.defineProperty(window, 'sessionStorage', { value: mock() });
Object.defineProperty(window, 'getComputedStyle', {
value: () => ['-webkit-appearance']
});
Last but not least we have to remove @types/jasmine
from node_modules and make sure you include jest as the new types in the tsconfig.spec.json
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsc/spec",
"baseUrl": "./",
"module": "commonjs",
"target": "es5",
"types": [
"jest",
"node"
]
},
"include": [
"**/*.spec.ts",
"**/*.d.ts"
]
}
Jest is based upon Jasmine there’s no need to change the test already present in the scaffolded application.
The only noticeable difference between Jest and Jasmine are the spies. A spy in Jest by default will be like a callThrough
, you will need to mock it if you want to emulate Jasmine spies.
We can now start testing our application and leverage the mocking functionality that Jest provide.
Services
Services are probably one of the easiest elements to test. In the end they are only ES6 classes and you can ( and you should ) test them directly without using TestBed. The reason why i’m saying this are multiples.
This code is based upon the new HttpClient
released with Angular 4.3 which makes things easier also when using TestBed.
We have the following API:
{
"DUB": {
"name": "Dublin"
...
},
"MAD": {
"name": "Madrid"
...
},
...
}
And our service needs to retrieve those airports and order them by they code and also to retrieve a single airport. I will use ramda to reduce the boilerplate of our code and spice up the code with some functional programming.
So the first one will be called fetchAll$
public fetchAll$(): Observable<any> {
return this.http.get('https://foo.bar.com/airports')
.map(toPairs)
.map(sortBy(compose(
toLower,
head
)));
}
We are transforming the API first into pairs [key, value] using the toPairs
function and after we sort all the airports by their codes.
The second method of our service needs to fetch a single airport instead:
public fetchByIATA$(iata: string): Observable<any|undefined> {
return this.http.get('https://foo.bar.com/airports')
.map(prop(iata));
}
fetchByIATA$
just return the value of the specific key, in this case IATA code, or undefined using prop
function from ramda.
This is the whole service:
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import 'rxjs/add/operator/map'
import {
toPairs,
compose,
head,
toLower,
prop,
sortBy
} from 'ramda';
@Injectable()
export class SampleService {
constructor(private http: HttpClient) {}
public fetchAll$(): Observable<any> {
return this.http.get('https://foo.bar.com/airports')
.map(toPairs)
.map(sortBy(compose(
toLower,
head
)));
}
public fetchByIATA$(iata: string): Observable<any|undefined> {
return this.http.get('https://foo.bar.com/airports')
.map(prop(iata));
}
}
We need here to mock the http response because we don’t want to hit the server for each test and remember we are doing unit tests not end to end or integration tests.
With the new HttpClient
there’s no need to configure mockBackend and this is such a relief.
First thing we have to configure TestBed to load our service with HttpClientTestingModule, this will give us the ability to intercept and mock our backend calls.
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ HttpClientTestingModule ],
providers: [
SampleService
]
});
});
After TestBed is configured we now can get our service to test and a mocking http controller from the injector:
beforeEach(
inject([SampleService, HttpTestingController], (_service, _httpMock) => {
service = _service;
httpMock = _httpMock;
}));
Now that we have all the setup we can proceed with the single tests
it('fetchAll$: should return a sorted list', () => {
const mockAirports = {
DUB: { name: 'Dublin' },
WRO: { name: 'Wroclaw' },
MAD: { name: 'Madrid' }
};
service.fetchAll$().subscribe(airports => {
expect(airports.length).toBe(3);
expect(airports[2][0]).toBe('WRO');
});
const req = httpMock.expectOne('https://foo.bar.com/airports');
req.flush(mockAirports);
httpMock.verify();
});
The new HttpClient
does actually reminds me of AngularJS v1.x way of testing http calls. We are defining what we are expecting from the function invocation and than through the httpMock
object we specify what calls we are expecting and what to return flush
. In the end we call the verify()
function to make sure that there are no pending connections.
Here’s the link to the full source code
Using Jest the previous suite will take this time:
Another option that we can explore is to mock directly HttpClient
, in this example we are only interested in the get
function which needs to return an observable containing our data.
We will not use TestBed at all but just use a mock function that Jest provides.
SampleService
requires HttpClient
in the constructor:
const http = {
get: jest.fn()
};
beforeEach(() => {
service = new SampleService(http);
});
We will pass our stub to the SampleService, and typescript will complain that there are missing properties for HttpClient. To overcome this we can:
Use always the as any
:
beforeEach(() => {
service = new SampleService(http as any);
});
If you don’t want to repeat the as any keyword or you can create a small function which will do it for you and then later on import it:
const provide = (mock: any): any => mock;
...
beforeEach(() => {
service = new SampleService(provide(http));
});
At this point we can specify the test like the following:
it('fetchAll$: should return a sorted list', () => {
http.get.mockImplmentationOnce(() => Observable.of(mockAirports));
service.fetchAll$().subscribe((airports) => {
expect(http.get).toBeCalledWith('https://foo.bar.com/airports');
expect(airports.length).toBe(3);
expect(airports[2][0]).toBe('WRO');
});
});
We are calling mockImplementationOnce
as the name describes it to mock it just once and return an Observable of our mockAirports, we are repeating the exact same assertion that we did before.
Here’s the link to the full source code
And this is the time of execution:
Let’s recap, a suite running two tests with TestBed is taking 99ms in total while without TestBed its only 12ms.
Note that i’ve used already in the second test jest advanced mock functions. I’m requesting directly a mock function through jest.fn()
, if you want to read more about those mock functions please have a look here.
Final comparison
Now that we have those two extra unit tests let’s try to run another time the two unit tests suite, one with Karma + Chrome, the other with Jest and see the results.
I’ve create the following script to track out the time on my local machine:
start=$SECONDS
yarn test -- --single-run
end=$SECONDS
echo "duration: $((SECONDS-start)) seconds elapsed.."
I’ve added the karma-verbose-reporter to get more informations about the tests and the total results is 22s.
For Jest the script to track time instead is the following:
start=$SECONDS
yarn test -- --verbose
end=$SECONDS
echo "duration: $((SECONDS-start)) seconds elapsed.."
--verbose
option will track the execution of each test inside the suite.
Jest is still leading with only 5s execution.
Bonus
I mentioned before the instant feedback, Jest will automatically run the tests related to the file that you modified. This is especially good while watching.
If we commit the changes to our repository and try to modify only app.component.ts
while running yarn test:watch
you will notice that only the app.component.spec.ts
is running:
Conclusion
I do believe that the frameworks should encourage testing and a crucial point is their speed. Jest provides all of this and we’ve already switched in Ryanair from karma + chrome to Jest.
PS: If you care about unit tests and Angular we are hiring at Ryanair, Join us.