JavaScript UnitTest Submit Guideline

kyolee310 edited this page Feb 12, 2015 · 11 revisions

Setup Unit-test Environment

Install npm

Install npm, a package manager for JavaScript, if missing.

On CentOS, run

yum install -y npm

Install devDependencies

At the project's home directory ./eucaconsole, run

npm install

to install npm packages listed in the file package.json.

The step above will install Grunt, Karma, and PhantomJS dependencies to construct the JavaScript Unit-test environment on your machine. And, run

npm install -g grunt-cli

to allow grunt command line tools to run on your console.


Run Unit-test

Run karma

grunt karma

The command above will trigger Jasmine unit-test and keep the test running in the background. When it detects that any of the JavaScript files listed in karma.conf.js has been updated, it will re-trigger the unit-test automatically.**

  • **On vim editor, you will need to set the option

:set backupcopy=yes

to allow the update of the JavaScript files to be detected. Without this setting, Karma thinks the JavaScript file has been deleted on each file edit, which results in refusing to run the section of the unit-test related to the edited file.

Run karma:ci - Single Run Mode

grunt karma:ci

Unlike the grunt karma command, it runs the unit-test only once and exits after. It is also known as Continuous Integration mode used by Travis CI.


Jasmine Specs

Directory and Spec Files

The directory for Jasmine Spec files is located at

./eucaconsole/static/js/jasmine-spec

The Jasmine Spec files contains the implementation of the JavaScript unit-test where each Jasmine Spec file is mapped to the Angular module used in this project. For instance, the unit-test

spec_security_group_rules.js

maps to the Angular module,

security_group_rules.js

karma.conf.js

When testing the Angular module SecurityGroupRules below, be sure that all the dependencies modules, ('SecurityGroupRules', ['CustomFilters', 'EucaConsoleUtils']) are listed in karma.conf.js file. If the test requires a template to be loaded, then the template also needs to be included in the list.

Angular Module:

angular.module('SecurityGroupRules', ['CustomFilters', 'EucaConsoleUtils'])
    .controller('SecurityGroupRulesCtrl', function ($scope, $http, $timeout, eucaUnescapeJson) {
        ... 
        $scope.isRuleNotComplete = true;
        ... 
    }); 

karma.conf.js:

files: [
    ... 
    'templates/panels/*.pt',
    'static/js/pages/custom_filters.js',
    'static/js/pages/eucaconsole_utils.js',
    'static/js/widgets/securitygroup_rules.js',
    'static/js/jasmine-spec/spec_security_group_rules.js',
    ...
]

Angular missing module error

When seeing a lengthy, jumbled error message as below, it is likely the issue with missing Angular modules on karma.conf.js:

Error: [$injector:modulerr] http://errors.angularjs.org/1.2.26/$injector/modulerr?p0=BucketContentsPage&p1=Error%3A%20%5B%24injector%3Amodulerr%5D%20http%3A%2F%2Ferrors.angularjs.org%2F1.2.26%
...
    at /root/seeds/eucaconsole/eucaconsole/static/js/thirdparty/angular/angular.min.js:34
    at r (/root/seeds/eucaconsole/eucaconsole/static/js/thirdparty/angular/angular.min.js:7)
...

How to Write Jasmine Unit-test for Angular Module

Basic Jasmine Unit-test Setup

A simple Jasmine unit-test setup consists of describe() and it() blocks:

describe("<test category>", function() {
    it("<literal description of test>", function() {
        // test procedure comes here
        expect(<value>).MATCHER();
    });
});

The list of the Jasmine native matchers can be found in Jasmine 2.0 Documentation

Angular Mock and beforeEach()

See below Jasmine unit-test sample code for setting up Angular mock module in beforeEach(). The function beforeEach() will be called before each function it() is run within describe().

Jasmine Unit-test:

describe("SecurityGroupRules", function() {

    beforeEach(angular.mock.module('SecurityGroupRules'));
    var scope, httpBackend, ctrl;
    beforeEach(angular.mock.inject(function($rootScope, $httpBackend, $controller) {
        scope = $rootScope.$new();
        httpBackend = $httpBackend;
        ctrl = $controller('SecurityGroupRulesCtrl', {
            $scope: scope
        });
    }));

    describe("Initial Values Test", function() {
        it("Initial value of isRuleNotComplete is true", function() {
           expect(scope.isRuleNotComplete).toBeTruthy();
        });
    });
});

spyOn() and toHaveBeenCalled()

Notice the Angular function resetValues() below contains a function call cleanupSelections(). If you want to write a unit-test to ensure that the function cleanupSelections() gets executed whenever resetValues() is invoked, Jasmine's spyOn() and toHaveBeenCalled() can be used as below:

Angular Module:

$scope.resetValues = function () {
    ...
    $scope.cleanupSelections();
    $scope.adjustIPProtocolOptions();
};

Jasmine Unit-test:

describe("Function resetValues() Test", function() {

    it("Should call cleanupSelections() after resetting values", function() {
        spyOn(scope, 'cleanupSelections');
        scope.resetValues();
        expect(scope.cleanupSelections).toHaveBeenCalled();
    });
});

setFixtures() - Template Loading

Not only ensuring function procedures, unit-test can also be used to prevent critical elements on a template from being altered.

The beforeEach() block below shows how to load a template before running unit-test. The template securitygroup_rules.pt will be loaded onto PhantomJS's environment so that a jQuery call, such as $('#inbound-rules-tab'), can be called to grab the static element on the template.

Jasmine Unit-test:

beforeEach(function() {
    var template = window.__html__['templates/panels/securitygroup_rules.pt'];
    // remove <script src> and <link> tags to avoid phantomJS error
    template = template.replace(/script src/g, "script ignore_src"); 
    template = template.replace(/<link/g, "<ignore_link"); 
    setFixtures(template);
});

describe("Template Label Test", function() {

    it("Should #inbound-rules-tab link be labeled 'Inbound'", function() {
        expect($('#inbound-rules-tab').text()).toEqual('Inbound');
    });
});

Notice above that template.replace() lines update the template's elements to disable <script src=""></script> and <link></link>. When the template is loaded onto PhantomJS, PhantomJS tries to continue loading other JS or CSS files appeared on the template. The loading of such files becomes an issue if their locations are not properly provided in the template -- for instance, the files contain dynamic paths, then PhantomJS results in error since it will not be able to locate the files. A workaround for this issue is to disable <script> and <link> elements on the template and, instead, load such files directly using the karma configuration list karma.conf.js.

Template is required

In some cases, when a function contains calls that interact with elements on the template, then you will have to provide the template so that the function call can complete without error. For instance, the function cleanupSelections below contains jQuery calls in the middle of procedure. Without the template provided, the function will not be able to complete the execution since those jQuery lines will error out.

Angular Module:

$scope.cleanupSelections = function () {
    ...
    if( $('#ip-protocol-select').children('option').first().html() == '' ){
        $('#ip-protocol-select').children('option').first().remove();
    }
    ...
    // Section needs to be tested
    ...
};

setFixtures() - Direct HTML Fixtures

In some situations, the static elements provided by the template will be not satisfy the needed condition for testing the function. For instance, the function getInstanceVPCName below expects the select element vpc_network to be populated with options. In a real scenario, the options will be populated by AJAX calls on load -- mocking such AJAX calls is described in the section below. However, if the intention is to limit the scope of testing for this specific function only, then you could directly provide the necessary HTML content in order to simulate the populated select options as seen in the setFixtures() call below:

Angular Module:

 $scope.getInstanceVPCName = function (vpcID) {
     ...
     var vpcOptions = $('#vpc_network').find('option');
     vpcOptions.each(function() {
         if (this.value == vpcID) {
             $scope.instanceVPCName = this.text;
         }  
     });
 }

Jasmine Unit-test:

beforeEach(function() {
    setFixtures('<select id="vpc_network">\
        <option value="vpc-12345678">VPC-01</option>\
        <option value="vpc-12345679">VPC-02</option>\
        </select>');
});

it("Should update instanceVPCName when getInstanceVPCName is called", function() {
    scope.getInstanceVPCName('vpc-12345678');
    expect(scope.instanceVPCName).toEqual('VPC-01');
});

Mock HTTP Backend

When writing unit-test for Angular modules, often it becomes necessary to simulate the interaction with the backend server. In that case, $httpBackend module can be used to set up the responses from the backend server for predetermined AJAX calls.

Angular Module:

    $scope.getAllSecurityGroups = function (vpc) {
        var csrf_token = $('#csrf_token').val();
        var data = "csrf_token=" + csrf_token + "&vpc_id=" + vpc;
        $http({
            method:'POST', url:$scope.securityGroupJsonEndpoint, data:data,
            headers: {'Content-Type': 'application/x-www-form-urlencoded'}
        }).success(function(oData) {
            var results = oData ? oData.results : [];
            $scope.securityGroupCollection = results;
        });
        ...
    }

Jasmine Unit-test:

describe("Function getAllSecurityGroups Test", function() {

    var vpc = 'vpc-12345678';

    beforeEach(function() {
        setFixtures('<input id="csrf_token" name="csrf_token" type="hidden" value="2a06f17d6872143ed806a695caa5e5701a127ade">');
        var jsonEndpoint  = "securitygroup_json";
        var data = 'csrf_token=2a06f17d6872143ed806a695caa5e5701a127ade&vpc_id=' + vpc 
        httpBackend.expect('POST', jsonEndpoint, data)
            .respond(200, {
                "success": true,
                "results": ["SSH", "HTTP", "HTTPS"]
            });
    });

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

    it("Should have securityGroupCollection[] initialized after getAllSecurityGroups() is successful", function() {
        scope.securityGroupJsonEndpoint = "securitygroup_json";
        scope.getAllSecurityGroups(vpc);
        httpBackend.flush();
        expect(scope.securityGroupCollection[0]).toEqual('SSH');
        expect(scope.securityGroupCollection[1]).toEqual('HTTP');
        expect(scope.securityGroupCollection[2]).toEqual('HTTPS');
    });
});

Also notice how setFixtures() is used in beforeEach() to prepare for the jQuery line var csrf_token = $('#csrf_token').val(); in the function getAllSecurityGroups().

Angular $watch test

$watch() is one of the most frequently used functions in Angular, which triggers events when it detects update in the watched object. When you need $watch() function to react in unit-test, you could call $apply() to have the latest update to be detected by the Angular module.

Angular Module:

$scope.setWatchers = function () {
    $scope.$watch('securityGroupVPC', function() {
        $scope.getAllSecurityGroups($scope.securityGroupVPC);
    });
};

Jasmine Unit-test:

it("Should call getAllSecurityGroupVPC when securityGroupVPC is updated", function() {
    spyOn(scope, 'getAllSecurityGroups');
    scope.setWatchers();
    scope.securityGroupVPC = "vpc-12345678";
    scope.$apply();
    expect(scope.getAllSecurityGroups).toHaveBeenCalledWith('vpc-12345678');
});

Angular $on test

In Angular,$on() is used to detect any broadcast signal from other Angular modules. For testing such setup, you could directly send out the signal by using $broadcast() call.

Angular Module:

$scope.setWatchers = function () {
    $scope.$on('updateVPC', function($event, vpc) {
        ...
        $scope.securityGroupVPC = vpc;
    });
};

Jasmine Unit-test:

it("Should update securityGroupVPC when updateVPC is called", function() {
    scope.setWatchers();
    scope.$broadcast('updateVPC', 'vpc-12345678');
    expect(scope.securityGroupVPC).toEqual('vpc-12345678');
});

Angular $broadcast and $emit test

Paired with $on(), you would also want to write unit-test for ensuring the $broadcast() call's condition. For such purpose, spyOn() and toHaveBeenCalledWith() setup can be used on $broadcast() to check for its proper signal signatures.

Angular Module:

$scope.setWatcher = function () {
    $scope.$watch('securityGroupVPC', function () {
        $scope.$broadcast('updateVPC', $scope.securityGroupVPC);
    });
};

Jasmine Unit-test:

it("Should broadcast updateVPC when securityGroupVPC is updated", function() {
    spyOn(scope, '$broadcast');
    scope.setWatcher();
    scope.securityGroupVPC = 'vpc-12345678';
    scope.$apply();
    expect(scope.$broadcast).toHaveBeenCalledWith('updateVPC', scope.securityGroupVPC);
});

Similar to $broadcast, you can also test $emit with the spyOn approach above:

Angular Module:

$scope.$emit('tagUpdate');

Jasmine Unit-test:

spyOn(scope, '$emit');
...
expect(scope.$emit).toHaveBeenCalledWith('tagUpdate');

Angular $timeout test

If Angular module contains instructions wrapped in $timeout() scope, $timeout.flush() call is needed in unit-test to ensure those instructions within $timeout() have been completed.

The example below shows a sample unit-test that is to verify whether the function updateTerminationPoliciesOrder gets called when $watch event is triggered. Notice the target function updateTerminationPoliciesOrder resides within $timeout() scope. This means that, on Jasmine's unit-test, timeout.flush() is needed to be called prior to examine the status of the target function updateTerminationPoliciesOrder. Also, notice how $timeout is injected into the Angular mock module, which is then passed to the unit-test spec function as a plain variable timeout.

Angular Module:

$scope.setWatch = function () {
    $scope.$watch('terminationPoliciesUpdate', function () {
        $timeout(function (){
            $scope.updateTerminationPoliciesOrder();
        });
    }, true);
};

Jasmine Unit-test:

var scope, ctrl, timeout;
beforeEach(angular.mock.inject(function($controller, $rootScope, $timeout) {
    scope = $rootScope.$new();
    // Handle $timeout() events in Angular module 
    timeout = $timeout;
    ctrl = $controller('ScalingGroupPageCtrl', {
        $scope: scope
    });
}));

...

it("Should call updateTerminationPoliciesOrder when terminationPoliciesUpdate is updated", function() {
    spyOn(scope, 'updateTerminationPoliciesOrder');
    scope.setWatch();
    scope.terminationPolicies = ['NewestInstance', 'ClosestToNextInstanceHour'];
    scope.$apply();
    timeout.flush();
    expect(scope.updateTerminationPoliciesOrder).toHaveBeenCalled();
});
You can’t perform that action at this time.
You signed in with another tab or window. Reload to refresh your session. You signed out in another tab or window. Reload to refresh your session.
Press h to open a hovercard with more details.