Unit Testing with $q Promises in AngularJS
How to Unit Test AngularJS components that use $q Promises
on
This post will teach you how to write unit tests for your AngularJS components that use promises. The most common type of test I have to write that uses promises is for controllers that have services injected with asynchronous functions.
Controller that uses Promises
Here’s an example controller that uses a service with an asynchronous function:
app.controller('SearchController', function ($scope, searchService) {
// The search service returns a promise API
searchService.search($scope.query)
.then(function(data) {
// This is set when the promise is resolved.
$scope.results = data;
})
.catch(function() {
// This is set in the event of an error.
$scope.error = 'There has been an error!';
});
});
Key Points:
-
The call the
searchService.search
returns a promise, because it’s an asynchronous operation. -
The promise instance returned wraps the asynchronous logic from the
search
function with a.then
function (called when the operation is completed successfully) and a.catch
function (called when something went wrong and it didn’t work).
To provide the full example, here’s the code for searchService
that’s used by the controller:
app.factory('searchService', function ($q, $http) {
var service = {};
service.search = function search (query) {
// We make use of Angular's $q library to create the deferred instance
var deferred = $q.defer();
$http.get('http://localhost/v1?=q' + query)
.success(function(data) {
// The promise is resolved once the HTTP call is successful.
deferred.resolve(data);
})
.error(function() {
// The promise is rejected if there is an error with the HTTP call.
deferred.reject();
});
// The promise is returned to the caller
return deferred.promise;
};
return service;
});
Key Points:
-
We use Angular’s $q service to create the promise.
-
What’s a Promise anyway? In short, it’s a pattern for making asynchronous code easier to work with i.e. an alternative to passing around lots of callback functions. For more reading, check out MDN’s article: Promise.
-
The
resolve
function links to the.then
function in our controller i.e. all is well, so we can keep our promise and resolve it. -
The
reject
function links to the.catch
function in our controller i.e. something went wrong, so we can’t keep our promise and need to reject it.
Unit Test for Controller that uses Promises
If you’re in a hurry for the solution, here is a unit test for the controller that mocks the service call. It includes an example for resolving and rejecting the promise. Read on for a more detailed breakdown of what the code is doing.
Here’s the code:
describe('Testing a Controller that uses a Promise', function () {
var $scope;
var $q;
var deferred;
beforeEach(module('search'));
beforeEach(inject(function($controller, _$rootScope_, _$q_, searchService) {
$q = _$q_;
$scope = _$rootScope_.$new();
// We use the $q service to create a mock instance of defer
deferred = _$q_.defer();
// Use a Jasmine Spy to return the deferred promise
spyOn(searchService, 'search').and.returnValue(deferred.promise);
// Init the controller, passing our spy service instance
$controller('SearchController', {
$scope: $scope,
searchService: searchService
});
}));
it('should resolve promise', function () {
// Setup the data we wish to return for the .then function in the controller
deferred.resolve([{ id: 1 }, { id: 2 }]);
// We have to call apply for this to work
$scope.$apply();
// Since we called apply, not we can perform our assertions
expect($scope.results).not.toBe(undefined);
expect($scope.error).toBe(undefined);
});
it('should reject promise', function () {
// This will call the .catch function in the controller
deferred.reject();
// We have to call apply for this to work
$scope.$apply();
// Since we called apply, not we can perform our assertions
expect($scope.results).toBe(undefined);
expect($scope.error).toBe('There has been an error!');
});
});
Key Points:
-
The most common sticking point with testing promises with the $q service is that we need to call
$scope.$apply();
before we can assert values in the test. -
To test in isolation, we make use of Jasmine’s
spyOn
function to return a mock promise instance when thesearch
function is called in the controller logic that is being tested.
In Depth Explanation
If the code we just looked at feels a little too magical, read on and let’s break down the different elements used in the test.
For a more succinct example, in this test we are going to test with promises directly and not include any additional code (controller, service etc). Here is the code in full, we will break down each concept after the code:
describe('Testing $q directly', function () {
var deferred;
var $q;
var $rootScope;
beforeEach(inject(function(_$q_, _$rootScope_) {
$q = _$q_;
$rootScope = _$rootScope_;
deferred = _$q_.defer();
}));
it('should resolve promise', function () {
var response;
deferred.promise.then(function(data) {
response = data;
});
deferred.resolve('Returned OK!');
$rootScope.$apply();
expect(response).toBe('Returned OK!');
});
it('should reject promise', function () {
var response;
deferred.promise.catch(function(data) {
response = data;
});
deferred.reject('There has been an Error!');
$rootScope.$apply();
expect(response).toBe('There has been an Error!');
});
});
Now let’s work through the code from top to bottom. Starting with the beforeEach
block:
beforeEach(inject(function(_$q_, _$rootScope_) {
$q = _$q_;
$rootScope = _$rootScope_;
deferred = _$q_.defer();
}));
Key Points:
-
This code is called before each unit test to inject the core angular services we need.
-
We use the
$q
instance to create a new deferred object that we can use for the tests. We do this by assigning the result of_$q_.defer();
to the local variable named deferred. A deferred object represents a function that is asynchronous. -
We need the
$rootScope
instance so that we can call$rootScope.$apply()
(we will explain why shortly). See Unit Test with $rootScope for more detail on using$rootScope
.
Next we will take a look at the first test:
it('should resolve promise', function () {
var response;
deferred.promise.then(function(data) {
response = data;
});
deferred.resolve('Returned OK!');
$rootScope.$apply();
expect(response).toBe('Returned OK!');
});
Key Points:
-
We use the instance of the deferred object we created in the
beforeEach
block to access the promise instance. Note that the service example earlier in the post returneddeferred.promise
, here we are using that object directly to simplify the example. -
From the
deferred.promise
object we can access thethen
function that will be called if/when the promise is resolved. Thethen
function sets the data argument to a local variable (called response) that we can use in the test assertion. -
Calling
deferred.resolve('Returned OK!');
resolves ourdeferred.promise
instance, which in turn will invoke thethen
function passing along the value given in the call toresolve
i.e. the string value'Returned OK!'
will be the value of thedata
argument in the callback function passed to ourthen
function. -
We must call
$rootScope.$apply();
for this to work. If we don’t, the value of theresponse
variable will be undefined. Why is this necessary? Because any promises made with the $q service to be resolved/rejected are processed upon each run of angular’s digest cycle. Conceptually, the call to.resolve
changes the state of the promise and adds it to a queue. Each time angular’s digest cycle runs, any outstanding promises will be processed and removed from the queue. For a little more detail on the digest cycle, see the diagram at the end of What are Scopes? from the official angular docs. -
Finally, we can check that everything worked as expected by checking that the
response
variable has the same string argument we passed to theresolve
function.
Next we will take a look at the second and final test:
it('should reject promise', function () {
var response;
deferred.promise.then(function(data) {
response = data;
});
deferred.promise.catch(function(data) {
response = 'Error: ' + data;
});
deferred.reject('500 Status');
$rootScope.$apply();
expect(response).toBe('Error: 500 Status');
});
Key Points:
-
The principles of this test are the same as with the resolved promise test we just discussed.
-
In this test, we call
deferred.reject
thus causing the promise to fail. In turn this means that.catch
is called and.then
will not be called. -
In this test we verify that the
response
value is the error message that we expect.
Putting it all Together
If we revisit the first unit test presented in the post, we can see how we are using Jasmine to create a mock for the search
function and to return a deferred promise instance:
spyOn(searchService, 'search').and.returnValue(deferred.promise);
In the controller code, it should now be clearer how the deferred.promise
instance we created via the Jasmine mock is being used to generate the .then
and .catch
functions:
searchService.search($scope.query)
.then(function(data) {
// This is set when the promise is resolved.
$scope.results = data;
})
.catch(function() {
// This is set in the event of an error.
$scope.error = 'There has been an error!';
});
This is a slightly longer form of the deferred.promise.then
and deferred.promise.catch
functions we explored in the more succinct example.
Example Test Code
Full code example of the tests used in this post via a Github Gist.