How to Unit Test $http in AngularJS - Part 2 - ngMock Fundamentals
How to unit test AngularJS code that uses the $http service with ngMock. Part 2 of 2.
on
This is the second post following on from How to Unit Test $http in AngularJS - Part 1. This post covers the remaining arguments of when
and expect
, the respond
function, and takes a deeper dive into the source code of ngMock’s $httpBackend
.
Using the when
and expect
API calls
In the previous post we looked at the arguments method
and url
, in this post we will look at the data
and headers
arguments and how we can use them in our tests.
To recap, the parameters we can pass are:
when(method, url, [data], [headers]);
And for expect
(they are the same):
expect(method, url, [data], [headers]);`
NB the [brackets] indicate ‘optional’.
As before, the arguments for when
and expect
are the same, so the following examples shown using when
are also applicable for expect
.
Using the data
parameter
This is an optional parameter that we use to verify data being sent to an http function is what we expect. This would be used for POST and PUT.
The data
argument can be of type:
- Object
- string
- RegExp
- function(string)
Object data
Example
In this example we pass the data as a standard JavaScript object. The Object must exactly match the data being sent in the code under test:
it('should post data (object)', inject(function($http) {
var $scope = {};
/* Code Under Test */
$http.post('http://localhost/auth', {
username: 'hardcoded_user',
password: 'hardcoded_password'
})
.success(function(data, status, headers, config) {
$scope.user = data;
});
/* End Code */
$httpBackend
.when('POST', 'http://localhost/auth', {
username: 'hardcoded_user',
password: 'hardcoded_password'
})
.respond({
username: 'hardcoded_user'
});
$httpBackend.flush();
expect($scope.user).toEqual({ username: 'hardcoded_user' });
}));
string data
Example
In this example we pass the data as a string. The string format will be equivalent to calling stringify on a JSON object:
it('should post data (string)', inject(function($http) {
var $scope = {};
/* Code Under Test */
$http.post('http://localhost/auth', {
username: 'hardcoded_user',
password: 'hardcoded_password'
})
.success(function(data, status, headers, config) {
$scope.user = data;
});
/* End Code */
$httpBackend
.when('POST', 'http://localhost/auth', '{"username":"hardcoded_user","password":"hardcoded_password"}')
.respond({
username: 'hardcoded_user'
});
$httpBackend.flush();
expect($scope.user).toEqual({ username: 'hardcoded_user' });
}));
RegExp data
Example
Here we use regex to match the data. This example regex allows for any user name value that has the password value ‘hardcoded_password’:
it('should post data (regex)', inject(function($http) {
var $scope = {};
/* Code Under Test */
$http.post('http://localhost/auth', {
username: 'hardcoded_user',
password: 'hardcoded_password'
})
.success(function(data, status, headers, config) {
$scope.user = data;
});
/* End Code */
$httpBackend
.when('POST', 'http://localhost/auth', /{"username":".*","password":"hardcoded_password"}/)
.respond({
username: 'hardcoded_user'
});
$httpBackend.flush();
expect($scope.user).toEqual({ username: 'hardcoded_user' });
}));
Function data
Example
In this exampe we use a function that returns a boolean to verify that we have a match. The function in this example returns true for all cases where the username value is ‘hardcoded_user’:
it('should post data (function)', inject(function($http) {
var $scope = {};
/* Code Under Test */
$http.post('http://localhost/auth', {
username: 'hardcoded_user',
password: 'hardcoded_password'
})
.success(function(data, status, headers, config) {
$scope.user = data;
});
/* End Code */
$httpBackend
.when('POST', 'http://localhost/auth', function(data) {
return angular.fromJson(data).username === 'hardcoded_user'
})
.respond({
username: 'hardcoded_user'
});
$httpBackend.flush();
expect($scope.user).toEqual({ username: 'hardcoded_user' });
}));
Using the headers
parameter
This is an optional parameter that we use to verify the headers being sent to an http function are what we expect. This would be useful in checking auth tokens on request headers, for example.
The headers
argument can be of type:
- Object
- function(Object)
Object headers
Example
In this example, we verify that the header property authToken has the value ‘teddybear’. Because the object has to match exactly, we also have to include the Accept property in the header:
it('should use Object', inject(function($http) {
var $scope = {};
/* code under test */
$http.get('http://localhost/foo', {
headers: { 'authToken': 'teddybear' }
})
.success(function(data, status, headers, config) {
$scope.fooData = data;
});
/* end */
$httpBackend
.when('GET', 'http://localhost/foo', undefined, {
authToken: "teddybear",
Accept: "application/json, text/plain, */*"
})
.respond(200, { data: 'value' });
$httpBackend.flush();
expect($scope.fooData).toEqual({ data: 'value' });
}));
Function headers
Example
Note in the last example we had to also include the Accept
propety to match the headers exactly. To match only on specific properties we could make use of the function argument as follows. In this example we can match just the authToken property from the headers of the code under test. The header object is passed to our callback function as an argument:
it('should use Function', inject(function($http) {
var $scope = {};
/* code under test */
$http.get('http://localhost/foo', {
headers: { 'authToken': 'teddybear' }
})
.success(function(data, status, headers, config) {
$scope.fooData = data;
});
/* end */
$httpBackend
.when('GET', 'http://localhost/foo', undefined, function(headers) {
return headers.authToken === 'teddybear';
})
.respond(200, { data: 'value' });
$httpBackend.flush();
expect($scope.fooData).toEqual({ data: 'value' });
}));
Using the respond
function
Calling when
or expect
returns an object that has a respond
function that we can use to configure the arguments that are passed to the http call under test once the http call is resolved (or in our case, when we call flush
).
There are two methods of setting what properties are to be sent to an http request once it is fulfilled. We can pass the arguments directly or via a single function. The function signature is as follows:
function([status,] data[, headers, statusText])
OR
function(function(method, url, data, headers))
The arguments are:
- Status - the http status code i.e. 200, 404, 500, 401 etc.
- Data - this would be the data returned from an HTTP request e.g. JSON data
- Headers - the HTTP headers that contains content type, auth details etc.
- statusText - at the time of writing, this doesn’t appear to work.
#### Passing individual arguments
The fist method of setting response parameters is via function arguments passed to respond
:
function([status,] data[, headers, statusText])
In this example we see how a success 200 status is returned, along with response data and headers for the matching URL based on the when
configuration:
it('should use respond with params', inject(function($http) {
var $scope = {};
/* code under test */
$http
.get('http://localhost/foo')
.success(function(data, status, headers, config, statusText) {
// demonstrates how the values set in respond are used
if (status === 200) {
$scope.fooData = data;
$scope.tokenValue = headers('token');
}
});
/* end */
$httpBackend
.when('GET', 'http://localhost/foo')
.respond(200, { data: 'value' }, { token: 'token value' }, 'OK');
$httpBackend.flush();
expect($scope.fooData).toEqual({ data: 'value' });
expect($scope.tokenValue).toEqual('token value');
}));
NB the values passed to respond
are sent to the success
function when the http request is resolved.
#### Passing a single function argument
The second method of setting the response values is via a function:
function(function(method, url, data, headers)
In this example, we create a generic when
object that matches any URL, but make use of the arguments of the HTTP call passed to the function callback to verify its properties e.g. URL being called, http verb:
it('should use respond with function', inject(function($http) {
var $scope = {};
/* Code Under Test */
$http
.get('http://localhost/foo')
.success(function(data, status, headers, config, statusText) {
// demonstrates how the values set in respond are used
if (status === 200) {
$scope.fooData = data;
$scope.tokenValue = headers('token');
}
});
/* End */
$httpBackend
.when('GET', /.*/)
.respond(function(method, url, data, headers) {
// example of how one might use function arguments to access request params
if (url === 'http://localhost/foo') {
return [200, { data: 'value' }, { token: 'token value' }, 'OK'];
}
return [404];
});
$httpBackend.flush();
expect($scope.fooData).toEqual({ data: 'value' });
expect($scope.tokenValue).toEqual('token value');
}));
NB note that our function returns an array to represent the values status
, data
, headers
, statusText
.
Re-using a respond function
When the respond
function is called its return value is the same object, which means that we can re-configure respond
on an existing when
or expect
object:
it('should re-use respond function', inject(function($http) {
var $scope = {};
/* code under test */
var foo = function() {
$http
.get('http://localhost/foo')
.success(function(data, status, headers, config, statusText) {
// demonstrates how the values set in respond are used
if (status === 200) {
$scope.fooData = data;
$scope.tokenValue = headers('token');
}
});
};
foo();
foo();
/* end */
var whenObj = $httpBackend
.when('GET', 'http://localhost/foo')
.respond(200, { data: 'value' }, { token: 'token value' }, 'OK');
$httpBackend.flush(1);
expect($scope.fooData).toEqual({ data: 'value' });
expect($scope.tokenValue).toEqual('token value');
whenObj.respond(200, { data: 'second value' }, { token: 'token value' }, 'OK');
$httpBackend.flush(1);
expect($scope.fooData).toEqual({ data: 'second value' });
expect($scope.tokenValue).toEqual('token value');
}));
Flush
We’ve already seen extensive use of flush
, which resolves all the pending HTTP requests that are matched against the test configuration. Using flush
gives us synchronous control over the asynchronous HTTP calls in the code under test.
We can optionally pass a numeric argument to flush
allowing us to resolve only a specific number of HTTP calls in sequential order. We demonstrated this in the previous code example. The following is code is the relevant snippet that uses flush([count])
:
var whenObj = $httpBackend
.when('GET', 'http://localhost/foo')
.respond(200, { data: 'value' }, { token: 'token value' }, 'OK');
// Resolve one HTTP request (starting from the first registered)
$httpBackend.flush(1);
expect($scope.fooData).toEqual({ data: 'value' });
whenObj.respond(200, { data: 'second value' }, { token: 'token value' }, 'OK');
// Resolve one more HTTP request (which would be the last of the two HTTP calls in the code under test)
$httpBackend.flush(1);
expect($scope.fooData).toEqual({ data: 'second value' });
Using the short methods
The when
and expect
functions are overloaded giving us shortcut access to the same underlying API. For example, for GET requests, we wouldn’t use the data
argument, so a shorter formed API is available:
whenGET(url, [headers]);
There is the same for expectGET
, whenPOST
etc. See the ngMock $httpBackend documentation for the full list of overloaded methods. Now that you understand the underlying methods it will be easy to follow.
Additional Features
The remaining functions of the $httpBackend API give us additional ways of verifying that our code is handling HTTP requests as we expect:
-
verifyNoOutstandingExpectation();
- this is also called internally onceflush
is called and will throw an exception if there are anyexpect
calls that have not been matched. -
verifyNoOutstandingRequest();
- verifies that there are no outstanding requests that need to be flushed. We would likely use this when callingflush
with an argument.
Finally, the following function allows us to re-set test expectations within the same test:
resetExpectations();
The following tests demonstrate these functions:
describe('using verify and reset', function() {
it('should demonstrate usage of verifyNoOutstandingExpectation and reset', inject(function($http) {
$httpBackend.expectGET('http://localhost/foo').respond(200);
$httpBackend.expectGET('http://localhost/bar').respond(500);
// without this, verifyNoOutstandingExpectation would throw an exception
$httpBackend.resetExpectations();
expect($httpBackend.verifyNoOutstandingExpectation).not.toThrow();
expect($httpBackend.verifyNoOutstandingRequest).not.toThrow();
}));
it('should demonstrate usage of verifyNoOutstandingRequest', inject(function($http) {
$httpBackend
.whenGET('http://localhost/foo')
.respond(200, { foo: 'bar' });
$http.get('http://localhost/foo');
$http.get('http://localhost/foo');
// NB we have only flushed the first http call, leaving one un-flushed
$httpBackend.flush(1);
expect($httpBackend.verifyNoOutstandingRequest).toThrow();
}));
});
Example Test Code
Full code example of the tests used in this post via a Github Gist.