AngularJS Unit Testing

What is Unit Testing?
- Testing logical functions or units of code in an isolated manner.
function sum(x, y) {
return x + y;
}
it('should add the values of x and y', function() {
var x = 5;
var y = 6;
expect(sum(x, y)).toBe(11);
});What is Not Unit Testing?
" A test is not a unit test if :
-
It talks to the database
-
It communicates across the network
-
It touches the file system
-
It can't run at the same time as any of your other unit tests
-
You have to do special things to your environment (such as editing config files) to run it."
http://www.artima.com/weblogs/viewpost.jsp?thread=126923
Why Unit Test?
- Catches errors that you might not have anticipated when initially building your code
- Ensures quality code
- Faster in catching errors in the code rather than debugging and tracing where the code broke.
//Given
function sum(x, y) {
return x + 1 + y;
}
//Test
it('should calculate the sum of x and y', function() {
var x = 5;
var y = 6;
expect(sum(x, y)).toBe(11); //FAILED! The sum ended up being 12...
});
Tools for Testing
Test Runner

Test Framework

Task Runner
Demo
- Agnostic JavaScript test framework runner
- Highly configurable
- Browsers (Phantom, FF, Chrome, etc)
- Test frameworks (Jasmine, Mocha, qunit)

Karma Configuration Sample
'use strict';
module.exports = function (config) {
var files = [
'vendors/angular.js',
'vendors/scripts/angular-mocks.js',
'vendors/scripts/jquery-1.10.2.min.js',
'test/myApp/someAppTest.js',
];
config.set({
basePath: '../', //The path which the test will run from
browsers: ['Chrome'] //The browser which the tests will run in
frameworks: ['jasmine'], //The JS test framework to use
files: files //The files that will be used for the test
});
};karma.config.js
2 ways to run a test through Karma
- IntelliJ Karma Plugin
- Command line
- Karma
- Grunt
- Karma
$ karma start <your_karma_config_filename>$ grunt karma:myTestKarma Config Structure
- It's good to have separate configuration files per feature and a separate consolidated configuration suite file.

Karma Config Structure (cont)
'use strict';
module.exports = function (config) {
var libs = [
'vendors/angular.js',
'vendors/scripts/ui-utils.min.js',
'vendors/scripts/angular-mocks.js',
'vendors/scripts/jquery-1.10.2.min.js',
'scripts/common/commonFiles.js',
{pattern: 'vendors/scripts/ui-bootstrap-*.js', included: true},
{pattern: 'vendors/scripts/angular-*.js', included: true}
];
return {
basePath: '../../',
frameworks: ['jasmine'],
files: libs,
//This function will be executed by karma to set the test configs
setConfig: function(files) {
config.set({
basePath: '../',
frameworks: ['jasmine'],
files: libs.concat(files)
});
}
}
};karma.shared.config.js
Karma Config Structure (cont)
//Import your test modules here.
var feature1Module = require('./karma.feature1.config.js'),
feature2Module = require('./karma.feature2.config.js'),
sharedModule = require('./karma.shared.config.js');
module.exports = function (config) {
var feature1 = feature1Module(config),
feature2 = feature2Module(config),
shared = sharedModule(config);
//We are concating test files together to be inserted into the test.
var files = shared.files.concat(feature1.files, feature2.files);
//Set the main test suite's configuration
config.set({
basePath: shared.basePath,
frameworks: shared.frameworks,
files: files
});
};karma.main.config.js
Karma Config Structure (cont)
'use strict';
module.exports = function (config) {
var sharedConfigModule = require('./karma.shared.config.js');
var sharedConfig = sharedConfigModule(config);
var feature1Files = [
{pattern: 'scripts/feature1/**.js', included: true},
{pattern: 'scripts/feature1/**/*.js', included: true},
{pattern: 'test/specs/feature1/*.js'}
];
//Set the configuration for feature1
sharedConfig.setConfig(feature1Files);
return {
files: feature1Files
}
};karma.feature1.config.js
- "Behavior-driven" development framework to test JavaScript.
- DOM is not required
- http://jasmine.github.io/2.0/introduction.html

Sample Test file
//A random "class" we want to test against
function MyMathClass() {
return {
sum: function(a, b) {
return a + b;
}
}
}
describe('A suite which tests simple arithmetic', function() {
var myMathClass;
//Before each test is executed, run this block of code
beforeEach(function() {
myMathClass = new MyMathClass();
});
//Your test scenario
it('should sum the values of two numbers', function() {
var x = 6,
y = 3;
var sum = myMathClass.sum(x, y);
expect(sum).toBe(9);
});
});Best Practices in Jasmine Unit testing
- Remember that you are testing the code. The concept of a view element from the DOM should not exist unless there is DOM manipulation in the code. Unit tests are different from End-to-End testing (e2e).
it('should display the button')E2E
Unit test
it('should set isBtnDisplayed to true')- Tests should be readable and descriptive
Best Practices in Jasmine Unit testing (cont.)
it('should join a manual mission', function () {
expect(scope.joinCard(scope.card, null)).toEqual(scope.joinManual(scope.card, null));
}); it('should join a manual mission', function () {
var missionSpy = spyOn(mockMissionsApiService, 'joinMission')
.and.callFake(ServiceFixtures.joinMissionsSuccess);
var emitSpy = spyOn($scope, '$emit').and.callThrough();
//Execution of the function that is being tested
$scope.joinManual(Fixtures.missionsInd10Card, Fixtures.deckOfCards);
$scope.$digest();
expect(missionSpy).toHaveBeenCalled();
expect(emitSpy).toHaveBeenCalledWith('CARD_JOINED',
[Fixtures.missionsInd10Card, Fixtures.deckOfCards]);
expect($scope.card.joined).toBeTruthy();
expect($scope.card.instanceId).toBe(
Fixtures.missionInd10Response.data.instance.instanceId);
});Vague and unable to understand
Descriptive
Grunt

Grunt can execute the karma test runners into your CI build process. This allows CI builds to pass or fail depending on your unit test status
Grunt Configuration
- Prerequisites - The grunt-karma plugin is installed (https://github.com/karma-runner/grunt-karma)
- Create a karma task with the appropriate configurations
grunt.initConfig({
karma: {
options: {
browsers: ['PhantomJS'],
singleRun: true
},
main: {
configFile: 'test/conf/karma.main.config.js'
}
}
});
grunt.registerTask('test', ['karma']);Grunt Configuration (cont)
- Run the grunt task which will run karma
$ grunt testYou can also run your Grunt task within IntelliJ

How does Angular fit into all of this?
- One of the benefits of Angular is that it was designed to be testable.
- Controllers, services, and other providers allow each component to be tested in an isolated manner (MVC)

Controllers
- Controllers are the glue for the view and model through the $scope (business logic stored in controllers)
angular.module('myApp')
.controller('myController', function($scope) {
$scope.sqrt = function(num) {
if (num < 0) throw new Error('Number must not be negative');
return Math.exp(Math.log(num) / 2);
};
});Controllers (cont.)
- To test a controller in Jasmine, Angular provides their own testing library (angular-mocks.js).
- Angular mocks provide test related code such dependency injection functions (inject(), module())
describe('MyCtrl test', function() {
var $scope;
//Declare the myApp module that will be used before each test is ran
beforeEach(module('myApp'));
//Inject the built in ngProviders of $rootScope and $controller. You can also
//inject any services that are part of myApp or angular.
beforeEach(inject(function($rootScope, $controller) {
//We are setting our local $scope to be a new scope from the rootScope
$scope = $rootScope.$new();
//Instantiate the controller and set its $scope to the local $scope for this test.
//This means that any functions or properties on the controller's $scope will now
//be accessible to the test's local $scope.
$controller('myCtrl', { $scope: $scope });
//Run a digest cycle before each test to update or reinitialize any values on $scope.
$scope.$digest();
}));
it('should be able to calculate the square root of numbers',
var result = $scope.sqrt(64);
expect(result).toBe(8);
});
});Controllers (cont.)
- How do you test a controller which calls a custom service?
- Remember that we are testing the controller, not the service.
angular.module('myApp')
.factory('myFactory', function($http) {
return {
someAjaxGet: function() {
return $http.get('/someUrl');
};
};
});angular.module('myApp')
.controller('myCtrl', function($scope, myFactory) {
$scope.data = undefined;
$scope.makeServiceCall = function() {
myFactory.someAjaxGet().then(function(data) {
$scope.data = data;
});
};
});myFactory.js
myCtrl.js
Is this a valid test?
Controllers (cont.)
it('should get the response from the service', function() {
var result = $scope.makeServiceCall();
expect($scope.data.success).toBe(result.success)
});
And error will be thrown where it doesn't know what $http is because myFactory uses it and was never injected into the test. Another issue is fact that the service call returns a promise. So how can we capture a successful callback when JavaScript runs asynchronously?
Controllers (cont.)
1. We need to mock out the service and spy on the service call
var $scope, $q, myFactoryMock;
//mock out the function call for myFactoryMock
var Fixtures = {
ajaxCallGetSuccess: function() {
var deferred = $q.defer();
deferred.resolve({success: true});
return deferred.promise;
}
};
beforeEach(module('myApp'));
beforeEach(inject(function($rootScope, $q, myFactory) {
$scope = $rootScope.$new();
//Set myFactoryMock to be myFactory
myFactoryMock = myFactory;
$controller('myCtrl', {$scope: $scope, myFactory: myFactoryMock});
$scope.$digest();
}));Controllers (cont.)
2. Create a spy on the service call. A spy stubs out a function call that is being tracked. Think of it as "when(someFunction.isCalled).then(doSomething)"
it('should set the data when the service is called', function() {
//We are spying on the 'someAjaxGet' function from myFactoryMock.
//When that function is called, call a fake success function
//that we stubbed out earlier. Be sure that the fake function
//being called returns what is return in the real world.
//Don't make up data just to make the test pass.
var spy = spyOn(myFactoryMock, 'someAjaxGet').and.callFake(ajaxCallSuccess);
$scope.makeServiceCall();
//Running the digest cycle will ensure that the promise has been returned.
$scope.$digest();
expect(spy).toHaveBeenCalled();
expect($scope.data.success).toBeTruthy();
})Services
- Same concept of testing controllers, except there is no $scope.
var mockMyFactory;
beforeEach(inject(function(myFactory) {
mockMyFactory = myFactory;
));
it('should make a HTTP GET request', inject(function($httpBacked, $q) {
var deferred = $q.defer();
var expectedResult = { success : true };
//Think of this like a spyOn for $http
$httpBackend.whenGET('/someUrl').respond(expectedResult);
spyOn(mockMyFactory, 'someAjaxGet').and.returnValue(deferred.promise);
mockMyFactory.someAjaxGet();
deferred.resolve(expectedResult);
$httpBackend.expectGET('/someUrl').respond(expectedResult);
$httpBackend.flush();
));Directives
- Can be a little more difficult to test
- Angular's mock library provides a great way to mock out the DOM and test how models affect the DOM
angular.module('myApp')
.directive('numberOnly', function() {
var linker = function($scope, $elem, $attr) {
//implementation will detect if a value is a number
var value = elem.val();
return isNaN(value);
};
return {
require: '^ngModel',
restrict: 'A',
scope: {
'value': '=ngModel',
},
link: linker
};
});<input number-only class="inputClass">Template
myDirective.js
Directives (cont.)
- Because we need a DOM element to test on, you can compile a template string into $scope with $compile
//compiles the template with the associated $scope
element = $compile(template)($scope);
Directives (cont.)
- $compile returns an angular Element. It contains a model controller called $ngModelController
- The ngModelController controls how an element is rendered from what's in $scope.
//retrieves the model controller from the template
ngModelController = element.data('$ngModelController');
//run the digest cycle to update what's in $scope
$scope.$digest();
//Update the model controller's values and render it onto the template
ngModelController.$setViewValue(value);
ngModelController.$render();Directives (cont.)
var $scope, $compile, template, element, ngModelController;
/**
* Helper function to compile a template and update the model and directive's view
*/
function compileTemplateAndDigest(value) {
$scope.model = value;
//compiles the template with the associated $scope
element = $compile(template)($scope);
//retrieves the model controller from the template
ngModelController = element.data('$ngModelController');
//run the digest cycle to update what's in #scope
$scope.$digest();
//Update the model controller's values and render it onto the template
ngModelController.$setViewValue(value);
ngModelController.$render();
}
beforeEach(module('numberOnly'));
beforeEach(inject(function($rootScope, _$compile_) {
$scope = $rootScope.$new();
$compile = _$compile_;
$scope.model = '';
template = '<input type="text" name="myInput" number-only precision="1" ng-model="model" />';
}));
it('should have the value of a whole number', function() {
compileTemplateAndDigest('123');
expect(element.val()).toBe('123');
});
it('should have the value of a decimal number', function() {
compileTemplateAndDigest('123.1');
expect(element.val()).toBe('123.1');
});Demo
To sum it all up...
- As your JavaScript code grows, it needs to have good test coverage.
- Unit testing is about testing the code's functionality. Not a feature's functionality.
- Karma and Jasmine make life easy when testing AngularJS code
- Write tests around business logic. Don't write unnecessary test code (ex. Testing that initialization of var has happened)
Resources
- Rally Health JS Unit Testing Guidebook:
http://bit.ly/1otXp13 - Jasmine:
http://jasmine.github.io/2.0/introduction.html - Karma:
http://karma-runner.github.io/0.12/index.html - Grunt:
http://gruntjs.com - AngularJS Unit Test Guide:
https://docs.angularjs.org/guide/unit-testing
Questions?
Unit Testing JavaScript
By Bryan Lin
Unit Testing JavaScript
- 1,596
