A Few Thoughts on Better Unit Tests For AngularJS Controllers

Thursday, May 15, 2014

There are a few aspects of unit testing AngularJS controllers that have made me uncomfortable over time. In this post I’ll describe some of these issues and what I’ve been trying on my current project to put the code in an acceptable state, as well as some general tips I’ve found useful.

Duplicated Setup Code

One approach to testing a controller with Jasmine is to use the module and inject helpers in a beforeEach block to create the dependencies for a controller. Most controllers will need multiple beforeEach blocks to setup the environment for different testing scenarios (there is the happy day scenario, the failing network scenario, the bad input scenario, etc). If you follow the pattern found in demo projects you’ll start to see too much code duplication in these setup blocks.

I’m comfortable with some amount of duplication inside of tests, as are others, however, the constant use of inject to bring in dependencies that 80% of the tests need becomes a noisy tax.

What I’ve been doing recently is using a single inject call per spec file in an opening beforeEach block. This block manually hoists all dependencies into the scope for other tests,  and also runs $http backend verifications after each test, even if they aren’t needed in every test.

var $rootScope, $controller, $q, $httpBackend, appConfig, scope;
beforeEach(inject(function (_$rootScope_, _$controller_, _$q_, _$httpBackend_, _appConfig_) {
    $q = _$q_;
    $rootScope = _$rootScope_;
    $controller = _$controller_;
    $httpBackend = _$httpBackend_;
    appConfig = _appConfig_;
    scope = $rootScope.$new();
}));

afterEach(function () {
    $httpBackend.verifyNoOutstandingExpectation();
    $httpBackend.verifyNoOutstandingRequest();
});

Now each scenario and the tests inside have all the core objects they need to setup the proper environment for testing. There is even a fresh scope object waiting for every test, and the rest of the test code no longer needs inject

describe("the reportListController", function () {

    beforeEach(function () {
        $httpBackend.when("GET", appConfig.reportUrl).respond([{}, {}, {}]);
        $controller("reportListController", {
            $scope: scope,
        });
        $httpBackend.flush();
    });

    it("should retrieve the reports to list", function() {
        expect(scope.reports.length).toBe(3);
    });
});

I believe this approach has been beneficial. The tests are leaner, easier to read, and easier to maintain.

inject Knows Underscores

Notice the injected function in the opening beforeEach uses parameter names like _$rootScope_ and _$q_. The inject function knows how to strip the underscores to get to the real service names, and since the parameters use underscores the variables in the outer scope can use pretty names like $rootScope and $q.

Only Give The Controller What The Test Needs

Sometimes I’ve seen examples using $controller that pass every dependency in the second parameter.

$controller("reportListController", {
    $scope: scope,
    $http: $http,
    $q: $q
});

Chances are the above code only really needs to pass $scope, because the injector will fill in the rest of the services as appropriate.

$controller("reportListController", {
    $scope: scope
});

Again there is less test code and the code is easier to maintain. Controller dependencies can change, but the test code doesn’t. Building your own mocks library like angular-mocks for custom services also helps this scenario, too. If you don’t need to “own” the dependency in a test, don’t bother to setup and pass the dependency to $controller.

Testing Controllers and Services as a Unit

Perhaps controversial, but I’ve started to write tests that do not mock services. Instead, I test a controller and most of the services the controller requires as a single unit. First let me give some details on what I mean, and then explain why I think this works well.

Let’s assume we have a reportListController that uses $scope, as well as two custom services that themselves use $http behind the scenes to communicate with the web server. Instead of having long complicated scenario setups with mocks and stubs, I usually only focus on a mock HTTP backend and scope.

$httpBackend.when("GET", appConfig.reportUrl).respond(reports);

$controller("reportListController", {
    $scope: scope
});

$httpBackend.flush();

This is a scenario for deleting reports, and the tests are relatively simple.

it("should delete the report", function () {
    $httpBackend.when("DELETE", appConfig.reportUrl + "/1").respond(200);

    scope.delete(reports[0]);
    $httpBackend.flush();
    expect(scope.reports.length).toBe(1);
});


it("should show a message", function () {
    $httpBackend.when("DELETE", appConfig.reportUrl + "/1").respond(200);

    scope.delete(reports[0]);
    $httpBackend.flush();
    expect($rootScope.alerts.length).toBe(1);
});

These two tests are exercising a number of logical pieces in the application. It’s not just testing the controller but also the model and also how the model interacts with two different services and how those services interact and respond to HTTP traffic.

I’m sure a few people will think these tests are blasphemous and the model and the services should be tested in isolation. However, I believe it is this type of right versus wrong thinking centered around “best practices” that severely limit the acceptance of unit testing in more circles. After years of mock object frameworks in other languages I’ve learned to avoid mocks whenever possible. Mock objects and mock methods generally:

  • make a test harder to read
  • make a test brittle
  • make it easier to produce false positives and false negatives

What I want to test in these scenarios is how the code inside the controller interacts with the services, because most of the logic inside is focused on orchestrating the underlying services to perform useful work. The code inside has to call service methods at the right time, and handle promises appropriately. I want to be able to change the implementation details without reworking the tests. These test work by providing an input (delete this report), and looking at the output (the number of reports), and only needs to provide some fake HTTP message processing to fill in the gaps.

If I had written two mock services for the controller and tested the services in isolation, I’d have more test code but less confidence that the system actually works.

Using Route Resolves Can Simplify Tests

Testing controllers that make service calls when instantiated can be a bit tricky, because everything has to be setup and in place before using the $controller service to instantiate the controller itself.

Using promise resolves in a route definition not only makes for an arguably better user experience, it also makes for easier controller testing because the controller is given everything it needs to get started. Both ui.router and ngRouter support resolves in a route definition, but since this post is already long in the tooth. We’ll look at using resolves in a future post.


Comments
gravatar John Reilly Thursday, May 15, 2014
Great post Scott - I'm doing a lot on AngularJS testing right now and this really helped. Should "$q, $q" actually read "$q: $q" in the "Only Give The Controller What The Test Needs" section? $controller("reportListController", { $scope: scope, $http: $http, $q, $q }); I guess you could argue that you're proving your own point just here. :-)
gravatar scott Thursday, May 15, 2014
@John - yes, good catch. I added that by hand just to show another un-useful parameter.
gravatar felipekm Thursday, May 15, 2014
@scottt I guess there is a mistake on your first code sentence: appConfig = _appConfig_b>    scope = $rootScope.$new();
gravatar scott Thursday, May 15, 2014
Thanks, @felipekm.
Tuesday, June 24, 2014
would it be possible to write describe('when deleted', function(){ $httpBackend.when("DELETE", appConfig.reportUrl + "/1").respond(200); scope.delete(reports[0]); $httpBackend.flush(); it("should delete the report", function () { expect(scope.reports.length).toBe(1); }); scope.delete(reports[0]); $httpBackend.flush(); }) expect(scope.reports.length).toBe(1); }); it("should show a message", function () { $httpBackend.when("DELETE", appConfig.reportUrl + "/1").respond(200); scope.delete(reports[0]); $httpBackend.flush(); expect($rootScope.alerts.length).toBe(1); });
gravatar hannes Tuesday, June 24, 2014
would it be possible to centralize the 3 coded rules for the delete setup ( 1. $httpBackend.when("DELETE", appConfig.reportUrl + "/1").respond(200); 2. scope.delete(reports[0]); 3. $httpBackend.flush();) in a nested describe like = describe('when deleted', function(){}). Then inside the it blocks, you would only need to set the expectations.
gravatar scott Tuesday, June 24, 2014
@hannes: You could certainly nest another scenario to avoid duplicating those rules. It's really a judgement call.
Comments are now closed.
by K. Scott Allen K.Scott Allen
My Pluralsight Courses
The Podcast!