Introduction
Angular.js was designed with testability in mind, so it was easy to write tests in it. Years passed and Angular 2 came out, completely rewritten from the ground up. They followed the same guidelines, only syntax changed, but writing the tests remained easy.
It is important to write tests for our code — by doing it, we can guard against someone breaking our code by refactoring or adding new features. This might have happened to you when someone added a small feature or added equivalent code transformations, and nothing worked afterwards. Writing tests can clarify the intention of the code by giving examples for usages. It can also reveal design flaws. When a piece of code is hard to test, there might be a problem with its architecture.
We will be writing tests for an authentication service. After starting with basic tests, we will be looking at detailed assertions on the requests of the Http
module.
To help you along the tutorial we will write our code in a freshly generated angular-cli project, which you can install with npm install -g angular-cli
. Also, angular-cli commands will be provided to speed up development. If you want to write the examples by yourself, create a new project with ng new angular2-testing
, and apply the changes at every step. File names will be in comments on top of the code snippet. The examples will be written in Typescript, which is the recommended language for writing Angular 2 applications.
Setting Up the Environment
Angular 2 depends heavily on Dependency Injection (DI) to instantiate Components, Services, Filters etc. Test frameworks are not aware of this mechanism, so we need to add wrappers around the built-in methods. If you are not familiar with Dependency Injection, there is an article about in the official documentation. Currently, wrappers only exist for Jasmine and since Mocha is not supported yet, we will be using the Jasmine test framework.
// config/karma-test-shim.js
return Promise.all([
System.import('@angular/core/testing'),
System.import('@angular/platform-browser-dynamic/testing')
]).then(function (providers) {
var testing = providers[0];
var testingBrowser = providers[1];
// optional
['addProviders', 'inject', 'async'].forEach(function(functionName) {
window[functionName] = testing[functionName];
});
testing.setBaseTestProviders(
testingBrowser.TEST_BROWSER_DYNAMIC_PLATFORM_PROVIDERS,
testingBrowser.TEST_BROWSER_DYNAMIC_APPLICATION_PROVIDERS
);
});
From version rc4, we don’t need to import Jasmine methods (describe, it, ...
), they will be wrapped by default. The addProviders
method stands for loading our dependencies before each test. It will set up a new Injector instance before every test with the given dependencies. Dependencies can be given as an array. It’s similar to the regular bootstrapping of the Angular application, where we will set up the dependency injection. After doing that, we can use inject
in our tests to automatically instantiate the dependencies from our injector.
Setting the 3 methods (addProviders, inject, async
) on the global scope is optional. It only helps while writing tests by skipping the import
statement for them in each test file.
To make everything work, we also need to add some basic providers (TEST_BROWSER_DYNAMIC_PLATFORM_PROVIDERS, TEST_BROWSER_DYNAMIC_APPLICATION_PROVIDERS
). We can now remove imports for these methods from the beginning of each spec file and re-run the tests with ng test
.
If you choose to add the methods on the global scope, you can remove the imports for them in the test files. This way, you get an error when running the tests with ng test
. The Typescript compiler is complaining: Cannot find name 'inject, addProviders'
, because these variables are declared on the global scope, but no definition is found for them. We can fix this by adding them to the list of definitions.
// src/typings.d.ts
declare var addProviders: any;
declare var inject: any;
declare var async: any;
Writing the First Test
First, we’ll write tests against the authentication service of our application. This service will allow the user to log in and it will store the authentication state. The new service can be generated with ng generate service user
.
// src/app/user.service.ts
import { Injectable } from '@angular/core';
@Injectable()
export class UserService {
private loggedIn: boolean = false;
isLoggedIn() {
return this.loggedIn;
}
}
Here is the basic service that stores the login state of the user. It doesn’t utilize the Injectable
decorator for now, but it will come in handy when we add the Http
service as a dependency.
It’s time to write our first test. We’ll check whether the isLoggedIn
method returns false
.
// src/app/user.service.spec.ts
import { addProviders, inject } from '@angular/core/testing';
import { UserService } from './user.service';
describe('UserServiceTest', () => {
beforeEach(() => {
addProviders([UserService]);
});
it('#isLoggedIn should return false after creation', inject([UserService], (service: UserService) => {
expect(service.isLoggedIn()).toBeFalsy();
}));
});
The first step is to set up the dependencies before every test run with addProviders
. Every element in the array will be available for Dependency Injection. After that, the UserService
can be used in the inject
method to retrieve an instance of the service. In the test, we call the isLoggedIn
method to check whether it’s falsy and it passes green. It is important that the type definition inside the inject
method’s callback is only for type checking, not for Dependency Injection.
Introducing the HTTP Service
Let’s add a method that can log in the user with an HTTP call.
// src/app/user.service.ts
import { Injectable } from '@angular/core';
import { Http, Headers } from '@angular/http';
@Injectable()
export class UserService {
...
constructor(private http: Http) {}
...
login(credentials) {
let headers = new Headers();
headers.append('Content-Type', 'application/json');
return this.http.post(
'/login',
JSON.stringify(credentials),
{ headers }
);
}
}
This is the first time that we make use of the Injectable
decorator. We import and add the Http
service to the dependencies inside the constructor and store it automatically with the private
keyword. With the Http
service, requests can be made towards the server.
Let’s take a closer look at the new login
method. It creates a request for the /login
endpoint with the given credentials and headers. The body needs to be JSON encoded, because Angular 2 currently only supports text content for the body of the request. If this feature becomes available, we can remove it. Finally, we need to give the Content-Type
in the headers to tell the server that we are sending JSON content.
After the code, let’s look at the tests. It throws an error saying No provider for Http! (UserService -> Http)
. The Http
service as a dependency is required by the service, the inject
method searches for it, but it doesn’t find any. It is because we haven’t provided it yet. An easy solution might be to add it quickly to the dependencies, but in this case the test would make real HTTP calls. Nobody wants that in a unit test, because the tests wouldn’t run in isolation and would depend on external systems.
Instead of the real one, provide a fake backend implementation. We don’t need to implement it, Angular 2 already has one.
// src/app/user.service.spec.ts
import { addProviders, inject } from '@angular/core/testing';
import { Http, BaseRequestOptions } from '@angular/http';
import { MockBackend } from '@angular/http/testing';
import { UserService } from './user.service';
...
beforeEach(() => {
addProviders([
MockBackend,
BaseRequestOptions,
{
provide: Http,
useFactory: (backendInstance: MockBackend, defaultOptions: BaseRequestOptions) => {
return new Http(backendInstance, defaultOptions);
},
deps: [MockBackend, BaseRequestOptions]
},
UserService
]);
});
...
The Http
service is provided through a factory method where we can alter it’s original setup. Instead of the real backend we give it a MockBackend
, which has the same interface as the original one, but doesn’t send HTTP requests. This MockBackend
will be able to record and answer to calls.
Now, our tests are green again.
The next step is to write a test against the login method. Until now the test we wrote was synchronous, but the test for the login
method won’t be. For asynchronous tests in Jasmine the done
callback given as a parameter must be called after the test is executed. It tells the test runner that the asynchronous test has ended.
// src/app/user.service.spec.ts
it('should send the login request to the server', (done) => {
done();
});
As you might have noticed, it has a different interface than the inject
method. To make them work together, the inject
needs to be moved to a beforeEach
step. This step will run before every test and instantiation can be moved here.
Here’s how the previous test looks like with dependencies refactored:
// src/app/user.service.spec.ts
describe('UserServiceTest', () => {
let subject: UserService = null;
beforeEachProviders(() => [ ... ]);
beforeEach(inject([UserService], (userService: UserService) => {
subject = userService;
}));
it('#isLoggedIn should return false after creation', () => {
expect(subject.isLoggedIn()).toBeFalsy();
});
});
Not only does this enable us to write asynchronous tests, but it also eliminates some duplication. Otherwise, we would have had to copy the inject
method to every test.
Writing the Test Against an HTTP Call
Finally, we’ll write the test against the login
method.
// src/app/user.service.spec.ts
import { Http, BaseRequestOptions, Response, ResponseOptions, RequestMethod } from '@angular/http';
import { MockBackend, MockConnection } from '@angular/http/testing';
...
describe('UserServiceTest', () => {
let subject: UserService = null;
let backend: MockBackend = null;
beforeEach(inject([UserService, MockBackend], (userService: UserService, mockBackend: MockBackend) => {
subject = userService;
backend = mockBackend;
}));
...
it('#login should call endpoint and return it\'s result', (done) => {
backend.connections.subscribe((connection: MockConnection) => {
let options = new ResponseOptions({
body: JSON.stringify({ success: true })
});
connection.mockRespond(new Response(options));
});
subject
.login({ username: 'admin', password: 'secret' })
.subscribe((response) => {
expect(response.json()).toEqual({ success: true });
done();
});
});
});
We needed an instance of MockBackend
to listen and answer the requests, so we added it to the inject
parameters after UserService
. When a request is made, it is emitted on the connections
Observable
property of the backend. These requests can respond with the mockRespond
method if we give it a Response
object. In addition to the response’s body, we can also provide other properties like headers. At the end of the test, we subscribe to the response of the login
method and assert if it has responded correctly.
We can improve it even more by making more assertions on the request. The request’s url, method, body and headers can also be checked.
// src/app/user.service.spec.ts
backend.connections.subscribe((connection: MockConnection) => {
expect(connection.request.method).toEqual(RequestMethod.Post);
expect(connection.request.url).toEqual('/login');
expect(connection.request.text()).toEqual(JSON.stringify({ username: 'admin', password: 'secret' }));
expect(connection.request.headers.get('Content-Type')).toEqual('application/json');
...
});
The first assertion is for its method type. Method types are represented as integers and the method-integer associations can be found on RequestMethod
as properties. The request’s body can be retrieved with the text
method. It returns the body as a string, so we need to JSON stringify the credentials object. The headers we set can be accessed via a simple getter method. With these in place, every important aspect of the request can be checked.
Now that we’ve added the assertions, our first unit test against an Http
service is complete.
Conclusion
In this tutorial, we managed to:
- Create a service that sends a request,
- Set up required dependencies for our tests with
beforeEachProviders
andinject
, - Fake the backend with
MockBackend
to make testing possible, and - Respond and make assertions for the request through
MockConnection
.
The code for the entire project is available in this GitHub repository.
We hope that this article showed you that writing tests for services isn’t a difficult task and encouraged you to write more tests to ensure good business behavior. If you have any comments and questions, feel free to leave them in the comments below.