Blog

Testing Promises in AngularJS

12 Oct, 2013
Xebia Background Header Wave

I like how testable AngularJS makes your code. But when you are testing code that makes use of promises, things can still be tricky.
I ran into a case where my test didn’t work and I didn’t understand why. Some understanding of how Angular has integrated Kris Kowal’s Q promises framework was required to figure out what was going on, and to get the test to work.

The code

My code consists of an AccountController which controls a simple account registration dialog. Users can submit their screenname and their realname, where upon the AccountController will make an asynchronous call to a userService, which will do the real work.
When the userService succeeds in creating the account, the user is relocated to a success page: if something goes wrong, an errormessage is added to the scope.
Note that in line 6, when asked to create an account, the userService does not immediately calls the success- or error callback, but it returns a promise.

Here’s the AccountController:

[code language=”javascript”]
angular.module(‘myApp’)
.controller(‘AccountController’, function ($scope, $location, userService) {
$scope.submit = function () {
$scope.error = null;

userService.createAccount($scope.screenname, $scope.realname).then(
function success() {
$location.path(‘/congratulations’);
},
function failed() {
$scope.error = ‘Could not create account, sorry’;
});
};
});
[/code]

And here is my initial Jasmine test

[code language=”javascript”]
describe(‘Controller: CreateAccountCtrl’, function () {
beforeEach(module(‘xtweetApp’));

var CreateaccountCtrl, scope;
// Initialize the controller and a mock scope
beforeEach(inject(function ($controller, $rootScope) {
scope = $rootScope.$new();
scope.screenname = ‘misja’;
scope.realname = ‘misja alma’;

CreateaccountCtrl = $controller(‘CreateAccountCtrl’, {
$scope: scope
});
}));

it(‘submit should call the userService with screenname and realname’, inject(function ($q, userService, $location) {
// Return a successful promise from the userService
var deferredSuccess = $q.defer();
spyOn(userService, ‘createAccount’).andReturn(deferredSuccess.promise);

spyOn($location, ‘path’);

scope.submit();

expect(userService.createAccount).toHaveBeenCalled();
expect(userService.createAccount).toHaveBeenCalledWith(‘misja’, ‘misja alma’);

deferredSuccess.resolve(); // resolves the promise

expect(scope.error).toBe(null);
expect($location.path).toHaveBeenCalledWith(“/congratulations”); // This fails!
}));
});
[/code]

It fails, with the message:
[code]
Error: Expected spy path to have been called with [ ‘/congratulations’ ] but it was never called.
[/code]

It appears that the success handler of the promise that was returned by the userService was never called. And neither was the error handler, because no error message had been put on the scope.
But why?

Angular’s digest cycle

To understand this, we need a little overview of Angular’s digest cycle.
Here’s a diagram showing Angular’s event loop:

Angular Digest Cycle

The diagram shows that when an event is received from the browser, Angular event handlers will be called within a apply() function call.
The apply function places the callback on the evalAsync queue and then invokes a call to digest().
It is the digest function that processes all callbacks on the evalAsync queue, checks if any watched variables have been modified, and if necessary processes the evalAsync queue again.
A longer explanation can be found here

Now how does this effect our promise?

When a promise is resolved, its callbacks are not called immediately. Instead,
Angular puts the promise.resolve callbacks on the evalAsync queue. There they wait until the queue will be processed. Only then the callbacks will be called.
And it is the above mentioned digest() method that processes the queue.

The solution

So, to get my test to work, I have to insert a call to digest after my promise is resolved, like so:

[code language=”javascript”]
it(‘submit should call the userService with screenname and realname’, inject(function ($q, userService, $location) {
var deferredSuccess = $q.defer();
spyOn(userService, ‘createAccount’).andReturn(deferredSuccess.promise);

spyOn($location, ‘path’);

scope.submit();

expect(userService.createAccount).toHaveBeenCalled();
expect(userService.createAccount).toHaveBeenCalledWith(‘misja’, ‘misja alma’);

deferredSuccess.resolve();
scope.$digest(); // This makes sure that all callbacks of promises will be called

expect(scope.error).toBe(null);
expect($location.path).toHaveBeenCalledWith(“/congratulations”);
}));
});
[/code]

And now the test passes!

Qxperts. We empower companies to deliver reliable & high-quality software. Any questions? We are here to help! www.qxperts.io

Questions?

Get in touch with us to learn more about the subject and related solutions

Explore related posts