Further to my last post of client side testing I thought I'd quickly share a problem I've encountered with promises while writing clientside tests for an Angular application using Jasmine and Sinon.

The Problem

As an example, I've got a products controller that uses a dataContext object to read an array of products. The dataContext.getProducts function uses a promise to get the products list asynchronously.

Products Controller

(function () {
    'use strict';

    var controllerId = 'products';
    angular.module('app').controller(controllerId, [
        'common', 'dataContext',
        function (common, dataContext) {

            var vm = this;
            vm.products = [];
            activate();

            function activate() {
                dataContext.getProducts()
                        .then(function (results) {
                            vm.products = results

                            // Custom controller activation
                            common.activateController([], controllerId);
                        });
            }
        }]);
})();

Data Context

(function () {
        'use strict';

        var serviceId = 'dataContext';
        angular.module('app').factory(serviceId, [
            "$q", "$timeout",
            function ($q, $timeout) {
                var service = {
                    getProducts: getProducts,
                    getOrders: getOrders
                };

                init();

                return service;

                function init() {
                    // Implementation...
                }

                function getProducts() {
                    var deferred = $q.defer();

                    // Resolve the promise with the collection of products.
                    deferred.resolve([
                            { id: 1, name: "Product 1" },
                            { id: 2, name: "Product 2" },
                            { id: 3, name: "Product 3" },
                            { id: 4, name: "Product 4" },
                            { id: 5, name: "Product 5" }
                    ]);

                    return deferred.promise;
                }

                function getOrders() {
                    // Implementation...
                }
            }]);
    })();

The Test

In the following example, I'll test that the dataContext.getProducts function is called and that the controller.products array has a length of 5 (as in the 5 products returned from the dataContext.getProducts).

(function () {

        describe("Products Controller", function () {

            var testContext = {
                $controller: null,
                $rootScope: null,
                stubs: [],
                mocks: [],
                spies: []
            };

            beforeEach(function () {
                module('app');

                inject(function ($rootScope, $controller, dataContext) {
                    testContext.$controller = $controller;

                    // Create a spie to check that the getProducts function was called
                    testContext.spies['getProducts'] = sinon.spy(dataContext, "getProducts");
                });
            });

            afterEach(function () {
                // Restore any stubs
                for (var key in testContext.spies) {
                    testContext.spies[key].restore();
                }
            });

            it('activation loads products list', inject(function ($rootScope, common, dataContext) {
                // Arrange/Act: Create and initialize the controller
                var controller = testContext.$controller('products', { common: common, dataContext: dataContext });

                // Assert: Check that dataContext.getProducts was called
                sinon.assert.calledOnce(testContext.spies['getProducts']);

                // Assert: Products have been populated
                expect(controller.products.length).toEqual(5);
            }))
        });
    })();

Running the above test in the Jasmine HTML runner gives me the following result.

Jasmine Result

So the test fails because the controller.products array hasn't been populated from the dataContext.getProducts function.

The Reason

The reason the test fails is because Jasmine tests run synchronously, and as such the test doesn't wait for the call to dataContext.getProducts to resolve and return the array of products.

The Solution

To solve this issue we need to refer to the Angular documentation on the $q service. On this page there's an example of an asyncGreet function, in here there's the following comment:


// since this fn executes async in a future turn of the event loop, we need to wrap
// our code into an $apply call so that the model changes are properly observed.

So, a call to $rootScope.$apply() means that the model changes will be properly observed when using promises. Hence, changing the test to the following means the test passes (note the call to $rootScope.$apply()).

(function () {

    describe("Products Controller", function () {

        var testContext = {
            $controller: null,
            $rootScope: null,
            stubs: [],
            mocks: [],
            spies: []
        };

        beforeEach(function () {
            module('app');

            inject(function ($rootScope, $controller, dataContext) {
                testContext.$controller = $controller;

                // Create a spie to check that the getProducts function was called
                testContext.spies['getProducts'] = sinon.spy(dataContext, "getProducts");
            });
        });

        afterEach(function () {
            // Restore any stubs
            for (var key in testContext.spies) {
                testContext.spies[key].restore();
            }
        });

        it('activation loads products list', inject(function ($rootScope, common, dataContext) {
            // Arrange/Act: Create and initialize the controller
            var controller = testContext.$controller('products', { common: common, dataContext: dataContext });

            // Call to ensure model changes (e.g. calls to functions are return promises) are properly observed
            $rootScope.$apply();

            // Assert: Check that dataContext.getProducts was called
            sinon.assert.calledOnce(testContext.spies['getProducts']);

            // Assert: Products have been populated
            expect(controller.products.length).toEqual(5);
        }))
    });
})();