AngularJS code touts its high degree of testability, which is a reasonable claim. In much of the documentation end to end tests are provided with the examples. Like so many things with Angular, however, I was finding that although unit testing was simple, it was not easy. Examples were sparse and though the official documentation provided some snippets of examples, putting it all together in my “real-world” case was proving challenging. So here I’ve written a little bit about how I ended up getting that wonderful green light for a passing build to show up.
Karma
Karma is a test runner for JavaScript that was created by the Angular team. It is a very useful tool as it allows you to automate tasks that you would otherwise have to do by hand or with your own cobbled-together collection of scripts (such as re-running your test suite or loading up the dependencies for said tests). Karma and Angular go together like peanut butter and jelly.
With Karma, you simply define a configuration file, start Karma, and then it will take care of the rest, executing the tests in the browser(s) of your choice to ensure that they work in the environments where you plan on deploying to. You can specify these browsers in the aforementioned configuration file. angular-seed, which I highly recommend, comes with a decent out-of-the-box Karma config that will allow you to hit the ground running quickly. The Karma configuration in my most recent project looks like this:
module.exports = function(config) {
config.set({
basePath: ‘../’,
files: [
‘app/lib/angular/angular.js’,
‘app/lib/angular/angular-*.js’,
‘app/js/**/*.js’,
‘test/lib/recaptcha/recaptcha_ajax.js’,
‘test/lib/angular/angular-mocks.js’,
‘test/unit/**/*.js’
],
exclude: [
‘app/lib/angular/angular-loader.js’,
‘app/lib/angular/*.min.js’,
‘app/lib/angular/angular-scenario.js’
],
autoWatch: true,
frameworks: [‘jasmine’],
browsers: [‘PhantomJS’],
plugins: [
‘karma-junit-reporter’,
‘karma-chrome-launcher’,
‘karma-firefox-launcher’,
‘karma-jasmine’,
‘karma-phantomjs-launcher’
],
junitReporter: {
outputFile: ‘test_out/unit.xml’,
suite: ‘unit’
}
})
}
You can install Karma with:
npm install -g karma
Jasmine
Most of the resources available at the time of writing for unit testing with Angular use Jasmine, a behavior-driven development framework for testing JavaScript code. That’s what I’ll be describing here.
To unit test an AngularJS controller, you can take advantage of Angular’s dependency injection and inject your own version of the services those controllers depend on to control the environment in which the test takes place and also to check that the expected results are occurring. For example, I have this controller defined in my app to control the highlighting of which tab has been navigated to:
app.controller(‘NavCtrl’, function($scope, $location) {
$scope.isActive = function(route) {
return route === $location.path();
};
})
If I want to test the isActive function, how do I do so? I need to ensure that the $location service returns what is expected, and that the output of the function is what is expected. So in our test spec we have a beforeEach call that gets made that sets up some local variables to hold our (controlled) version of those services, and injects them into the controller so that those are the ones to get used. Then in our actual test we have assertions that are congruent with our expectations. It looks like this:
describe(‘NavCtrl’, function() {
var scope, $location, createController;
beforeEach(inject(function ($rootScope, $controller, _$location_) {
$location = _$location_;
scope = $rootScope.$new();
createController = function() {
return $controller(‘NavCtrl’, {
‘$scope’: scope
});
};
}));
it(‘should have a method to check if the path is active’, function() {
var controller = createController();
$location.path(‘/about’);
expect($location.path()).toBe(‘/about’);
expect(scope.isActive(‘/about’)).toBe(true);
expect(scope.isActive(‘/contact’)).toBe(false);
});
});
With this basic structure, you can set up all kinds of stuff. Since we are providing the controller with our own custom scope to start with, you could do stuff like setting a bunch of properties on it and then running a function you have to clear them, then make assertions that they actually were cleared.
$httpBackend
But what if you are doing stuff like using the $http service to call out to your server to get or post data? Well, Angular provides a way to mock the server with a thing called $httpBackend. That way, you can set up expectations for what server calls get made, or just ensure that the response can be controlled so the results of the unit tests can be consistent.
This looks like this:
describe(‘MainCtrl’, function() {
var scope, httpBackend, createController;
beforeEach(inject(function($rootScope, $httpBackend, $controller) {
httpBackend = $httpBackend;
scope = $rootScope.$new();
createController = function() {
return $controller(‘MainCtrl’, {
‘$scope’: scope
});
};
}));
afterEach(function() {
httpBackend.verifyNoOutstandingExpectation();
httpBackend.verifyNoOutstandingRequest();
});
it(‘should run the Test to get the link data from the go backend’, function() {
var controller = createController();
scope.urlToScrape = ‘success.com’;
httpBackend.expect(‘GET’, ‘/slurp?urlToScrape=http:%2F%2Fsuccess.com’)
.respond({
“success”: true,
“links”: [“http://www.google.com”, “http://angularjs.org”, “http://amazon.com”]
});
// have to use $apply to trigger the $digest which will
// take care of the HTTP request
scope.$apply(function() {
scope.runTest();
});
expect(scope.parseOriginalUrlStatus).toEqual(‘calling’);
httpBackend.flush();
expect(scope.retrievedUrls).toEqual([“http://www.google.com”, “http://angularjs.org”, “http://amazon.com”]);
expect(scope.parseOriginalUrlStatus).toEqual(‘waiting’);
expect(scope.doneScrapingOriginalUrl).toEqual(true);
});
});
As you can see, the beforeEach call is very similar, with the only exception being we are getting $httpBackend from the injector rather than $http. However, there are a few notable differences with how we set up the other test. For starters, there is an afterEach call that ensures $httpBackend doesn’t have any outstanding expectations or requests after each test has been run. And if you look at the way the test is set up and utilizes $httpBackend, there are a few things that are not exactly intuitive.
The actual call to $httpBackend.expect is fairly self-explanatory, but it is not in itself enough- we have to wrap our call to $scope.runTest, the function we are actually testing in this case, in a function that we pass to $scope.$apply, so that we can trigger the $digest which will actually take care of the HTTP request. And as you can see, the HTTP request to $httpBackend will not resolve until we call $httpBackend.flush(), so this allows us to test what things should be like when the call is in progress but hasn’t returned yet (in the example above, the controller’s $scope.parseOriginalUrlStatus property will be set to ‘calling’ so we can display an in-progress spinny).