Last Updated: February 25, 2016
·
1.701K
· kazark

A Strategy for Testing Behaviors of Angular Controllers

<small>Disclaimer: I am still an Angular noob. These are my current ideas on the subject and do not represent expert advice. Yes, even though this is a "pro tip" :).</small>

tl;dr

Do not unit test the implementation details of your controller. Treat it as a state machine and mutate its state by resolving and rejecting promises returned from mocked HTTP calls, or by functions set on the scope which are for user interactions. Write your asserts against the scope, and against HTTP calls if necessary.

Prolix version

If your unit tests depend on the details of the implementation of your controller, they will be brittle. Encapsulation should be your friend when writing unit tests. Test the behaviors of the controller. What are its behaviors? Generally, how it mutates the state of its scope, and whatever HTTP requests it makes. Test at that level, not at the level of any of its internal functions. I believe this is simply good behavior-oriented unit testing practice applied to Angular controllers.

Suppose you have an Angular controller MyPageCtrl which depends on several services that wrap calls to HTTP resources and return promises; call them foo and bar. Something like this (disclaimer: this is conceptional; I have not actually run this example):

controller('MyPageCtrl', ['$scope', '$q', 'foo', 'bar', function($scope, $q, foo,  bar) {
    var ctrl = this;

    ctrl.load = function(data) {
        $scope.loading = true;
        $q.all({
            foo: foo.get(),
            bar: bar.get()
        }).then(ctrl.onSuccess, ctrl.onFailure);
    };

    ctrl.onSuccess = function(data) {
        $scope.foo = data.foo;
        $scope.baz = data.bar.baz;
        $scope.loading = false;
    };

    ctrl.onFailure = function() {
        $scope.errorOccurred = true
    };

    ctrl.load();
}]);

Don't test the member functions of ctrl directly. Rather, write a test that $scope.errorOccurred is set when the foo.get() promise is rejected, and one that it is set when the $foo.bar() promise is rejected. Then write one that asserts that $scope.foo and $scope.baz are set when both promises are resolved. Oh, also, you'll probably want to test that $scope.loading is set as expected, of course. So something like (conceptual and fragmentary):

describe('mymodule MyPageCtrl', function() {
    var ctrl;
    var $scope;
    var apply;
    var foo;
    var bar;
    var deferreds = {};

   beforeEach(function() {
        module('mymodule');
        inject(function($controller, $rootScope, $q) {
            $scope = $rootScope.$new;
            apply = function() {
                $rootScope.$apply();
            };
            deferreds.foo = {
                get: $q.defer()
            };
            deferreds.bar = {
                get: $q.defer()
            };
            foo = {
                jasmine.createSpy('foo', ['get']).andReturn(deferreds.foo.get.promise)
            };
            bar = {
                jasmine.createSpy('bar', ['get']).andReturn(deferreds.bar.get.promise)
            };
            ctrl = $controller('MyPageCtrl', function() {
                $scope: $scope,
                foo: foo,
                bar: bar
            });
        });
   });

   it('should set the loading flag to true on initialization', function() {
       expect($scope.loading).toBe(true);
   });

    it('should set the error flag when it fails to retrieve foo', function() {
        deferreds.foo.get.reject();
        apply();

        expect($scope.errorOccurred).toBe(true);
    });
    ...
    it('should set foo and baz on the scope when both requests succeed', function() {
        deferreds.foo.get.resolve({ some: 'data' });
        deferreds.bar.get.resolve({ baz: 'more data' });
        apply();

        expect($scope.errorOccurred).toBe(false);
        ...
    });
    ...
});

At this point, you can refactor your controller at your leisure. "No! It's structured horribly! This guy doesn't know anything about Angular controllers!" Well, you should be able to fix that with little impact on the unit tests. They don't care whether or not you have a function called ctrl.load.

You get the idea. That's my two cents. But I'm new to Angular, so feel free to chip in with corrections, improvements or other comments.