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

  1. IntelliJ Karma Plugin
  2. Command line 
    • Karma
       
    • Grunt
       
$ karma start <your_karma_config_filename>
$ grunt karma:myTest

Karma 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

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

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 test

You 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

Questions?

Unit Testing JavaScript

By Bryan Lin

Unit Testing JavaScript

  • 1,596