When we write a web application, most of the time it has a backend. The most straightforward way to communicate with the backend is with HTTP requests. These requests are crucial for the application, so we need to test them. More importantly, these tests need to be isolated from the outside world. In this article I will show you how to test your requests properly and elegantly.
This article is the second part of a series in which I share my experiences testing different building blocks of an Angular application. It relies heavily on Dependency Injection based testing and it is recommended that you read the first part if you are not familiar with the concepts.
- Services
- HTTP Services (this article)
- Components
- Pipes
- Routing
Testing our first request
To get started we will test a basic request, the GET request. It will call a parameterized url without a body or additional headers. The Github API has an endpoint for retrieving public profile information about users. The profile information is returned in JSON format.
import { Injectable } from '@angular/core';
import { Http, Response } from '@angular/http';
import 'rxjs/add/operator/map';
@Injectable()
export class GithubService {
constructor(private http: Http) {}
getProfile(userName: string) {
return this.http
.get(`https://api.github.com/users/${userName}`)
.map((response: Response) => response.json());
}
}
The getProfile
method sends a GET request to the API and returns the response.
Every request made with the HttpModule
returns an Observable.
The returned value will always be a Response
object, which can return the response body.
With the help of the json
or text
method we can transform the value of the Observable.
The first thing we have to do is to set up the test dependencies.
The Http
dependency is required.
If we don't provide it, we will get this error message: No provider for Http!
.
beforeEach(() => {
TestBed.configureTestingModule({
providers: [GithubService],
imports: [HttpModule]
});
});
The problem with the real HttpModule
is that we will end up sending real HTTP requests.
It is an absolutely terrible idea to do this with unit tests, because it breaks the test's isolation from the outside world.
Under no circumstances will the result of the test be guaranteed.
For example, the network can go down and our well-crafted tests will no longer work.
Instead, Angular has a built-in way to fake HTTP requests.
import { MockBackend, MockConnection } from '@angular/http/testing';
import { Http, BaseRequestOptions, Response, ResponseOptions, RequestMethod } from '@angular/http';
...
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
GithubService,
MockBackend,
BaseRequestOptions,
{
provide: Http,
useFactory: (mockBackend: MockBackend, defaultOptions: RequestOptions) => {
return new Http(mockBackend, defaultOptions);
},
deps: [MockBackend, BaseRequestOptions]
}
]
});
});
Instead of providing Http
as a module,
it is better to use the factory provider and pass the MockBackend
instance to the Http
constructor.
This way the fake backend captures every request and can respond accordingly.
Before writing the first test it is also important to get an instance of the MockBackend,
because without it we won't be able to respond to requests.
beforeEach(inject([GithubService, MockBackend], (github, mockBackend) => {
subject = github;
backend = mockBackend;
}));
Let's write the first test that checks the result of the request.
it('should get profile data of user', (done) => {
let profileInfo = { login: 'sonic', id: 325, name: 'Tester' };
backend.connections.subscribe((connection: MockConnection) => {
let options = new ResponseOptions({ body: profileInfo });
connection.mockRespond(new Response(options));
});
subject.getProfile('blacksonic').subscribe((response) => {
expect(response).toEqual(profileInfo);
done();
});
});
Requests made are available through the connections
property of the fake backend as an Observable.
When it receives the request through the subscribe
method we can respond with a JSON object.
In our example only the response body is set. In addition, you can set the status and the headers of the request.
Another new element is the done
callback that is passed into the test function.
It is needed when writing asynchronous tests.
This way the test doesn't end when the execution of the function ends.
It will wait until the done
callback is called.
Of course, there is a timeout for hanging tests that don't call this done
method within a given interval.
HTTP requests are asynchronous by nature,
but the fake backend we use responds to them synchronously (it calls the subscribe
method synchronously).
You may wonder what makes the test asynchronous, then.
The answer is false positive tests.
If we comment out the response to the request, the test will still pass, even though we have an assertion.
The problem here is that the subscribe
callback never gets executed if we don't respond to the request.
it('should get profile data of user', () => {
// backend.connections.subscribe...
subject.getProfile('blacksonic').subscribe((response) => {
expect(response).toEqual(profileInfo);
});
});
Checking the request
Until now we haven't made any assertions for the request. For example, what was the called url, or what was the method of the request? To make the test more strict we have to check these parameters.
backend.connections.subscribe((connection: MockConnection) => {
expect(connection.request.url).toEqual('https://api.github.com/users/blacksonic');
expect(connection.request.method).toEqual(RequestMethod.Get);
...
});
The original Request
object resides on the MockConnection
object.
With its url
and method
property, we can add the assertions easily.
Digging deeper
GET requests are good for retrieving data, but we'll make use of other HTTP verbs to send data. One example is POST. User authentication is a perfect fit for POST requests. When modifying data stored on the server we need to restrict access to it. This is usually done with a POST request on the login page.
Auth0 provides a good solution for handling user authentication. It has a feature to authenticate users based on username and password. To demonstrate how to test POST requests, we will send a request to the Auth0 API. We won't be using their recommended package here, because it would abstract out the actual request, but for real-world scenarios I would recommend using it.
@Injectable()
export class Auth0Service {
constructor(private http: Http) {}
login(username: string, password: string) {
let headers = new Headers({
'Content-Type': 'application/json'
});
let options = new RequestOptions({ headers });
return this.http
.post(
'https://blacksonic.eu.auth0.com.auth0.com/usernamepassword/login',
{ username, password, client_id: 'YOUR_CLIENT_ID' },
options
)
.map((response: Response) => response.text());
}
}
The main difference between this example and the previous one is that here we are sending a JSON payload to the server
and appending additional headers onto it.
We don't have to manually JSON.stringify
the payload --- the request methods will take care of it.
The response will be in text format, so this time we don't have to convert anything to JSON.
Let's look at the test to see how we can check every detail of the request.
it('should be called with proper arguments', (done) => {
backend.connections.subscribe((connection: MockConnection) => {
expect(connection.request.url).toEqual('https://blacksonic.eu.auth0.com.auth0.com/usernamepassword/login');
expect(connection.request.method).toEqual(RequestMethod.Post);
expect(connection.request.headers.get('Content-Type')).toEqual('application/json');
expect(connection.request.getBody()).toEqual(JSON.stringify(
{
username: 'blacksonic',
password: 'secret',
client_id: 'YOUR_CLIENT_ID'
}, null, 2
));
...
});
subject.login('blacksonic', 'secret').subscribe((response) => {
expect(response).toEqual('<form />');
done();
});
});
The headers are also available on the Request
object and can be checked one by one.
The payload can be retrieved with the getBody
method.
This method always returns the body converted to a string, which will we see in the network traffic.
When we send JSON it will contain the output of the JSON.stringify
method: printed with spaces and an indentation of two.
Refactoring
The previous setup works, but it has multiple problems.
- For every service we test, the provider configuration will be exactly the same.
- The subscription to the outgoing connection responds the same immediately, regardless of the url.
- The assertions are verbose and hard to read.
Those who have tested their HTTP services in Angularjs may remember how simple the setup was for those tests. Angularjs provided convenient methods for setting expectations on requests.
Angular doesn't have those built-in functionalities, but very similar ones are present in the ngx-http-test library.
It can solve the problems mentioned earlier. Let's look at the test with the library for the Github profile fetch.
...
import { FakeBackend } from 'ngx-http-test';
describe('GithubServiceRefactored', () => {
...
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
GithubService,
FakeBackend.getProviders()
]
});
});
beforeEach(inject([GithubService, FakeBackend], (github, fakeBackend) => {
subject = github;
backend = fakeBackend;
}));
it('should get profile data of user', (done) => {
backend
.expectGet('https://api.github.com/users/blacksonic')
.respond(profileInfo);
subject.getProfile('blacksonic').subscribe((response) => {
expect(response).toEqual(profileInfo);
done();
});
});
});
The setup becomes a function call to FakeBackend.getProviders()
.
Setting the expectation hides the subscription and gives more readable methods like expectGET
.
The login test also becomes less verbose.
it('should be called with proper arguments', (done) => {
backend.expectPost(
'https://blacksonic.eu.auth0.com.auth0.com/usernamepassword/login',
{
username: 'blacksonic',
password: 'secret',
client_id: 'YOUR_CLIENT_ID'
},
{ 'Content-Type': 'application/json' }
).respond(responseForm);
subject.login('blacksonic', 'secret').subscribe((response) => {
expect(response).toEqual('</form>');
done();
});
});
Aside: Securing Angular Applications with Auth0
Are you building a product with Angular? We at Auth0, can help you focus on what matters the most to you, the special features of your product. Auth0 can help you make your product secure with state-of-the-art features like passwordless, breached password surveillance, and multifactor authentication. We offer a generous free tier to get started with modern authentication.
We provide the simplest and easiest to use User interface tools to help administrators manage user identities including password resets, creating and provisioning, blocking and deleting users.
Conclusion: What we've learned about Angular HTTP testing
In this tutorial, we managed to:
- setup tests and fake an HTTP backend
- write assertions for requests
- refactor the tests to be more readable
Angular has the tools to test HTTP requests, but still lacks the readable assertion methods that were present in Angularjs. Until such methods are implemented, the ngx-http-test library can be used.
To see the tests in action check out this GitHub repository.