Oh how far we’ve come in the web development world. Cross-browser support and performance, once touted as core features, are now table stakes. Meanwhile, automated tests have become pretty much standard in today’s JavaScript-driven applications. So naturally, we are beginning to expect frameworks to optimize around separation of concerns and testability. AngularJS came on the scene with its 1.0 release in 2012 and was “designed from the ground up to be testable.”
Or so they say.
Here we’ll examine just how easy it is to unit test AngularJS front-ends and what that can mean for today’s applications.
Dependency Injection
Unit testing is all about isolation and dependency injection makes isolation a breeze. Seasoned Java developers have known this for years. The Spring framework, ubiquitous in Java backends today, introduced dependency injection (or at least popularized it) and brought a great number of benefits in terms of decoupling, modularity, flexibility and testability. Simply put, dependency injection lets you swap out real implementations for mock ones and it damn near forces you to decouple every component.
Karma Overview
Karma is a test runner designed to work with AngularJS. It is agnostic of test frameworks and allows teams to easily execute tests in real browsers and headlessly. It integrates easily into continuous integration systems and build tools. Karma is incredibly easy to work with so rolling your own config should be straightforward enough. However, using a standard project template like ngBoilerplate will make things even easier.
Working with Jasmine
While there are several options for unit testing Angular, Jasmine is default. Jasmine’s syntax is simple and semantic and well, a little silly at times. The structure of a unit test in Jasmine looks like this:
describe('my Module') { var variables; //Expose items constructed in beforeEach() beforeEach() {} //Any setup that needs to happen for each test afterEach() {} //Any cleanup needed for each test it() { //Setup, Act, Expect } it() { } //Additional tests }
Getting familiar with Jasmine’s matchers and spies is really the only involved task. Fortunately, fitting Jasmine to AngularJS is incredibly easy thanks to dependency injection.
var controller, scope; beforeEach(module('myModule')); beforeEach(inject(function($controller, $rootScope) { scope = $rootScope.$new(); controller = $controller('MyCtrl', { $scope: scope }); }));
ngMock
The ngMock module is injected during tests and replaces some core components of the framework to better accommodate the needs of unit tests.
$timeout
As tests are part of the development workflow, tests must execute extremely quickly. Angular solves this by forcing all $timeout calls to execute immediately after the current thread completes, e.g. setTimeout(myFn, 0); In most cases, using timeouts is frowned upon, but there are obviously many cases where they’re strictly necessary. It’s important to keep in mind that code cannot rely on specific timing to work properly within unit tests.
$httpBackend
In order to isolate functionality and improve execution time, unit tests should never call out for data. The $httpBackend service automatically replaces $http (Angular’s Ajax service) so all such calls can be mocked.
There are two key concepts to employ with $httpBackend: expect and when. Simply put expect() tells your test to expect that a call is made and made correctly. On the other hand, when() allows you to inject a mock response so that you can test that the data is used properly. Both of these are absolutely critical to effective unit testing of Angular apps.
One brilliant feature of $httpBackend is its flush() method. Rather than forcing asynchronous test code, flush() allows tests to explicitly “execute” pending requests. This allows you to avoid the usual array of promises, callbacks and closures in your test code.
Testing Controllers
Controllers tend to be the easiest to test. Here is a simple example of a Controller and its unit test:
angular.module('MyModule').controller('MyCtrl', function($scope) { $scope.value = 0; $scope.maxValue = 3; $scope.incrementValue = function() { if ($scope.value < $scope.maxValue) { $scope.value++; } else { $scope.value = 0; } }; }); describe('MyCtrl', function() { var scope, controller; beforeEach(inject(function($controller, $rootScope) { scope = $rootScope.$new(); controller = $controller('MyCtrl', { $scope: scope }); })); it('has correct initial values', function() { expect(scope.value).toBe(0); expect(scope.maxValue).toBe(3); }); it('increments correctly', function() { scope.incrementValue(); expect(scope.value).toBe(1); scope.incrementValue(); expect(scope.value).toBe(2); scope.incrementValue(); expect(scope.value).toBe(3); scope.incrementValue(); expect(scope.value).toBe(0); }); });
Let’s take our ridiculously trivial example and make it a little more complicated. Instead of hardcoding the initial values. Let’s get them via an XHR:
angular.module('MyModule').controller('MyCtrl', function($scope, $http) { $scope.incrementValue = function() { if ($scope.value < $scope.maxValue) { $scope.value++; } else { $scope.value = 0; } }; $http.get('api/incrementor/config').success(function(data) { $scope.value = data.initialValue; $scope.maxValue = data.maxValue; }); }); describe('MyCtrl', function() { var scope, createController, httpBackend; beforeEach(inject(function($controller, $rootScope, $httpBackend) { scope = $rootScope.$new(); httpBackend = $httpBackend; createController = function() { return $controller('MyCtrl', { $scope: scope, $http: $httpBackend }); }; })); it('sets correct initial values', function() { httpBackend.expectGET('api/incrementor/config').respond(200, { initialValue: 0, maxValue: 3 }); createController(); httpBackend.flush(); expect(scope.value).toBe(0); expect(scope.maxValue).toBe(3); }); it('increments correctly', function() { scope.incrementValue(); expect(scope.value).toBe(1); scope.incrementValue(); expect(scope.value).toBe(2); scope.incrementValue(); expect(scope.value).toBe(3); scope.incrementValue(); expect(scope.value).toBe(0); }); });
Testing Directives
When writing directives there are a couple of things to consider:
- Use templateUrl for any non-trivial templates
- Business logic should go in the directive’s controller function
- All DOM manipulation belongs in the directive’s link function
When testing directives there are corresponding considerations:
- Use html2js preprocessor to more easily handle templateUrl directives
- The directive’s controller function can be tested similarly to a standalone controller
- DOM manipulation can (and should) be unit tested
Each directive test will look roughly like the following:
it('tests myDirective', function() { var element = $compile('')($rootScope); //test element to see what it contains //test functions and values on $rootScope like in a standalone controller });
Testing Services and Filters
Testing Services and Filters should be straightforward after mastering controllers. The same principles apply.
Coverage Goals
Ask a random developer how important code coverage is, the answer will likely be directly proportional to how high his/her code coverage is. The truth is there is no universally right answer. For those new to unit testing, work to get the most complex bits of logic under test first. Once you have more fluency and have learned to write more easily testable code, expand coverage to whatever makes sense for your project.
Arguably the easiest and most valuable code to unit test will likely be the controllers. Start there and expand to filters, services and directives. Directives are listed last here because they can often be the most challenging. But keep in mind, directives are meant to be reusable, which mean bugs and regressions can be both more likely and more disastrous.
End-to-End Considerations
As we’ve covered, unit tests validate individual pieces of functionality. End-to-end tests are needed to make sure those modules work together properly. They are there to validate user stories. Modus will follow up with a full post on this topic.
Tips for Avoiding Headaches
- No DOM manipulation in controllers
- Separate controller and link code in directives
- Each function should have one clear purpose
- Don’t hide key functionality with closures
- Just go ahead and bookmark $httpBackend docs now
- Focus on quality over quantity when unit testing
Conclusions
AngularJS along with Jasmine and Karma do indeed make unit testing easy. Following the guidelines above should help lessen the learning curve and encourage more easily testable code.
Having strong unit tests behind your application provide peace of mind and are incredibly valuable once you’ve hit production. However, unit tests are not sufficient. E2E tests must be part of your workflow and not surprisingly, Angular has just the solution for that too.
Jay Garcia
Related Posts
-
AngularJS: Tricks with angular.extend()
AngularJS has this built-in method for doing object to object copies called angular.extend(). It is…
-
Testing AngularJS Apps with Protractor
Do the words “automated” and “tests” make you cringe a little? Creating automated tests for…