Unit Testing with Promises in AngularJS made Easier
A Tip for Unit Testing with $q Promises in AngularJS with ngMock.
on
This post demonstrates a utility function that I use in my AngularJS unit tests to make the test code for promises more readable, and to reduce some boiler plate code. For a primer on unit testing with promises, see this post Unit Testing with $q Promises in AngularJS.
To setup some context, consider what a unit test would look like for this controller code:
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!';
});
});
We might write a test like along the lines of this:
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!');
});
});
In this test there are a few areas of complexity and duplication that could be improved upon. Such as:
-
We have to manually include the
$q
angular library and create a new deferred instance via_$q_.defer();
. -
We then need to use Jasmine’s
spyOn
function in order to return a promise via our new deferred instance for the service function called in the controller code. -
In the test, we need to setup the
resolve
orreject
function e.g.deferred.resolve([{ id: 1 }, { id: 2 }]);
that links back to thespyOn
function’s returned promise. -
We are also required to call
$scope.$apply();
in order for our calls todeferred.resolve
ordeferred.reject
to be executed, thus connecting everything together.
This solution aims to remove these complexities from the test code, resulting in the following simplified code for the same test:
describe('Testing a Controller that uses a Promise', function () {
var $scope;
var utils;
beforeEach(module('testUtils'));
beforeEach(module('search'));
beforeEach(inject(function($controller, _$rootScope_, searchService, _utils_) {
$scope = _$rootScope_.$new();
utils = _utils_;
$controller('SearchController', {
$scope: $scope,
searchService: searchService
});
}));
it('should resolve promise', function () {
utils.resolvePromise(searchService, 'search', [{ id: 1 }, { id: 2 }]);
$scope.$apply();
expect($scope.results).not.toBe(undefined);
expect($scope.error).toBe(undefined);
});
it('should reject promise', function () {
utils.rejectPromise(searchService, 'search');
$scope.$apply();
expect($scope.results).toBe(undefined);
expect($scope.error).toBe('There has been an error!');
});
});
Now all that’s required is to:
-
Include the module for use in the test:
beforeEach(module('testUtils'));
-
To resolve or reject the promise via a single line of code:
-
To resolve the promise: utils.resolvePromise(myService, ‘theFunctionName’, myDataToReturn);
-
To reject the promise: utils.rejectPromise(myService, ‘theFunctionName’);
-
Here’s the code for the testUtils module:
'use strict';
angular.module('testUtils', [])
.factory('utils', function utilsFactory($q) {
var setupPromise = function(object, method, data, resolve) {
spyOn(object, method).and.callFake(function() {
var deferred = $q.defer();
if (resolve) {
deferred.resolve(data);
} else {
deferred.reject(data);
}
return deferred.promise;
});
};
var service = {};
service.resolvePromise = function(object, method, data) {
return setupPromise(object, method, data, true);
};
service.rejectPromise = function(object, method, data) {
return setupPromise(object, method, data, false);
};
return service;
});