AngularJS Portlet Testing with Jasmine

Benito Gonzalez,
Senior Software Developer

Recently, I had the opportunity to add a new testing suite to a portlet. That suite is Jasmine, a JavaScript testing framework. The Survey Portlet is a modern portlet which leverages AngularJS. The Angular controllers needed to be tested as part of the project. In most Maven-driven Java applications that I have seen there is no testing performed on JavaScript. Jasmine integration is a step to correct this common shortcoming.

Apereo portlets use Maven as their build tool. Maven is extensible via plugins that can be triggered during different build phases such as testing. Some plugins integrate testing frameworks. One such Maven plugin integrates Jasmine. As is common, the documentation is a bit out of date at this time. It took some tinkering, but I finally made it work. Here is how it was done.

Pulling in JavaScript Libraries

One challenge was figuring out how to make JavaScript dependencies available for the tests. Luckily, Maven Central has many client-side web libraries available thanks to WebJars. Essentially, JavaScript code is wrapped in a JAR and uploaded to Central. It is then available as a common Maven dependency. In Survey Portlet's POM, the following three were needed for Jasmine to test the Angular controllers:

        <dependency>
            <groupId>org.webjars</groupId>
            <artifactId>angularjs</artifactId>
            <version>1.4.3</version>
        </dependency>
        <dependency>
            <groupId>org.webjars</groupId>
            <artifactId>underscorejs</artifactId>
            <version>1.8.3</version>
        </dependency>
        <dependency>
            <groupId>org.webjars.bower</groupId>
            <artifactId>angular-mocks</artifactId>
            <version>1.4.3</version>
        </dependency>

These libraries can then be used by Jasmine after a bit more configuration in the plugin entry.

Faking the Browser

The next thing we need is a browser simulator. Given that the tests should run on most builds, we do not want to depend on a full-blown browser. Plus, the build environment is unknown. PhantomJS provides a headless WebKit with no required browser. The Jasmine Maven plugin documentation claims PhantomJS is used by default; however, the PhantomJS plugin was needed with the Survey Portlet configuration.

        <plugin>
             <groupId>com.github.klieber</groupId>
             <artifactId>phantomjs-maven-plugin</artifactId>
             <version>0.7</version>
             <executions>
                 <execution>
                     <goals>
                         <goal>install</goal>
                     </goals>
                 </execution>
             </executions>
             <configuration>
                 <version>1.9.2</version>
             </configuration>
         </plugin>

There seemed to be a pathing issue with the Jasmine plugin finding PhantomJS. Adding the above entry in the POM file makes the plugin available.

Jasmine, Finally!

Finally with the WebJar libraries and PhantomJS available, the Jasmine Maven plugin can be configured.

        <plugin>
              <groupId>com.github.searls</groupId>
              <artifactId>jasmine-maven-plugin</artifactId>
              <version>2.1</version>
              <executions>
                  <execution>
                      <goals>
                          <goal>test</goal>
                      </goals>
                  </execution>
              </executions>
              <configuration>
                  <jsSrcDir>${project.basedir}/src/main/webapp/js</jsSrcDir>
                  <jsTestSrcDir>${project.basedir}/src/test/webapp/js</jsTestSrcDir>
                  <preloadSources>
                      <source>/webjars/angularjs/angular.js</source>
                      <source>/webjars/underscorejs/underscore.js</source>
                      <source>/webjars/angular-mocks/angular-mocks.js</source>
                  </preloadSources>
                  <sourceIncludes>
                      <include>*.js</include>
                  </sourceIncludes>
                  <specIncludes>
                      <include>*Spec.js</include>
                  </specIncludes>
                  <webDriverClassName>org.openqa.selenium.phantomjs.PhantomJSDriver</webDriverClassName>
                  <webDriverCapabilities>
                      <capability>
                          <name>phantomjs.binary.path</name>
                          <value>${phantomjs.binary}</value>
                      </capability>
                  </webDriverCapabilities>
                  <haltOnFailure>true</haltOnFailure>
              </configuration>
          </plugin>

There are three things to note in the configuration of this portlet. First, the attributes related to our Javascript are jsSrcDir, jsTestSrcDir, sourceIncludes and specIncludes. These are project dependent. Second, the WebJars are are declared in the preloadSources section. Note that the path starts with a backslash. (In the Jasmine Maven Plugin documentation this is omitted.) I found that omitting it made the WebJars unavailable during testing. Third, the web driver section must be declared to set the binary path to PhantomJS. Notice that this configuration is using the property that the PhantomJS Plugin made available.

Place the Code and Run

Now the portlet is configured to run Jasmine tests during the test phase. Based on the example above, the JavaScript files to test will be in src/main/webapp/js matching . The test specs will be in src/test/webapp/js matching *Spec.js. Running mvn test or another phase that includes testing will fire the Jasmine tests.

In Survey Portlet, there are two AngularJS apps we test in the files summary.js and survey.js. They are both located in src/main/webapp/js/. Let's look at the smaller of the two: summary.js.

    window.up.startSurveySummaryApp = function(window, params) {
    'use strict';

    if (!window.angular) {
        // Angular not defined, look for <script>
        var ANGULAR_SCRIPT_ID = 'angular-uportal-script';
        var scr = document.getElementById(ANGULAR_SCRIPT_ID);
        if (!scr) {
            // Load angular.js via <script>
            scr = document.createElement('script');
            scr.id =  ANGULAR_SCRIPT_ID;
            scr.type =  'text/javascript';
            scr.async =  true;
            scr.charset =  'utf-8';
            scr.src =  'https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.3.15/angular.js';
            document.body.appendChild(scr);
        }
        // Call boostrap() on script load
        scr.addEventListener('load', bootstrap);
    } else {
        bootstrap();
    }

    /*
     * Set up Survey Summary AngularJS app.
     *
     * Create app, register components and kick off app tied to portlet div.
     */
    function bootstrap() {
        var MODULE_NAME = params.n + '-survey-summary';
        var app = angular.module(MODULE_NAME, []);
        app.value("surveyName", params.surveyName);
        register(app);
        angular.bootstrap(document.getElementById(params.n + '-survey-summary-portlet'), [MODULE_NAME]);
    }

    /*
     * Register components in Survey Summary AngularJS app.
     *
     * Add directives, controllers and services to app.
     */
    function register(app) {

        app.controller('summaryController', function($scope, surveyName, surveyApiService) {
            // some test data omitted for clarity
            surveyApiService.getSurveyByName(surveyName).success(function(response) {
                console.log(response);
                $scope.survey = response;
                if ($scope.survey.id) {
                    surveyApiService.getSummary($scope.survey.id).success(function(response) {
                        console.log(response);
                        $scope.summary = response;
                    });
                }
            });
        });  // summaryController

        app.factory('surveyApiService', function($http) {
            var surveyApi = {};

            surveyApi.getSurveyByName = function(name) {
                return $http({
                    method: 'GET',
                    url: '/survey-portlet/v1/surveys/surveyByName/' + name
                });
            }

            surveyApi.getSummary = function(surveyId) {
                return $http({
                    method: 'GET',
                    url: '/survey-portlet/v1/surveys/' + surveyId + '/summary'
                });
            }

            return surveyApi;
        });  // surveyApiService

    }  // register()
    };  // window.up.startSurveySummaryApp()

Because we are in a portal environment, special handling is needed to initialize the AngularJS module. The code before the register(app) takes care of that for us. The controller is very simple. It uses the surveyApiService to retrieve and store the survey. If successful, it then fetches and saves the related summary.

There are two Jasmine test specs for summary.js and survey.js named summarySpec.js and surveySpec.js. They are both located in src/test/webapp/js/. Let's look at the one that corresponds to the above JavaScript: summarySpec.js.

    'use strict';

    window.up.startSurveySummaryApp(window, {n: 'test', surveyName: 'surTest'});

    describe('SurveySummaryApp', function() {

        var $rootScope, $scope, $controller, surveyTest, summaryTest;

        // retrieve the module created above test
        beforeEach(module('test-survey-summary'));

        // set local vars to controller and scopes
        beforeEach(inject(function(_$rootScope_, _$controller_, $httpBackend) {
            $rootScope = _$rootScope_;
            $scope = $rootScope.$new();
            $controller = _$controller_;

            // test data
            surveyTest = {
                    title: "Test Title",
                    id: 8,
                    description: "Test Description"
            };
            summaryTest = {
                    "responseCount": 1,
                    "answerCounts": {
                            "Please indicate the type of childcare needed to attend college":
                                    {"Onsite/Child Development Center":1},
                            "Please indicate the number of hours you are currently employed":
                                    {"1-10 hours":1},
                            "Indicate the types of courses you desire to take":
                                    {"Face-to-face courses":1,
                                     "Hybrid (both online and face-to-face courses)":1,
                                     "Online (including telecourse)":1}}};


            // controller gets a survey and answers
            $httpBackend.expect('GET', '/survey-portlet/v1/surveys/surveyByName/surTest')
                .respond(200, JSON.stringify(surveyTest));
            $httpBackend.expect('GET', '/survey-portlet/v1/surveys/8/summary')
                .respond(200, JSON.stringify(summaryTest));

            // create controller
            $controller('summaryController', {'$rootScope': $rootScope, '$scope': $scope});
            $httpBackend.flush();
        }));

        it('survey name should be provided', inject(function(surveyName) {
            expect(surveyName).toEqual('surTest');
        }));

        it('survey should be attached to scope', function() {
            expect($scope.survey).toEqual(surveyTest);
        });

        it('should get a survey', inject(function(surveyApiService, $httpBackend) {
            $httpBackend.expect('GET', '/survey-portlet/v1/surveys/surveyByName/sTest')
                .respond(200, '{"success": "true","id": 123 }');

            surveyApiService.getSurveyByName('sTest')
                .success(function(response) {
                    expect(response.id).toEqual(123);
                    expect(response.success).toBeTruthy();
            });

            $httpBackend.flush();
        }));
    })

First, the Jasmine test spec creates the app by calling the initializing function from the summary.js. The describe() function names a collection of tests for the test report. Next, there is a call to beforeEach() to prepare the app/module for use. Another beforeEach() sets up the controller, test data and the $http to return the test data when called. Finally we start the actual tests with calls to it(). The first one simply checks that the the survey name we passed in at the beginning is set correctly. The second test checks that the survey data in the scope matches the test data "sent" from $http. The last test checks that surveyApiService uses $http as expected to retrieve surveys by name.

So what does the output look like? There are a lot of log lines about starting the tooling to support Jasmine, but here is the test report for Survey Portlet:

    -------------------------------------------------------
     J A S M I N E   S P E C S
    -------------------------------------------------------
    [INFO] 
    SurveySummaryApp
      survey name should be provided
      survey should be attached to scope
      should get a survey

    SurveyApp
      survey name should be provided
      survey should be attached to scope
      should get a survey

    Results: 6 specs, 0 failures, 0 pending

And here is a sample report where I changed the expected value of the survey name:

    -------------------------------------------------------
     J A S M I N E   S P E C S
    -------------------------------------------------------
    [INFO] 
    SurveySummaryApp
      survey name should be provided <<< FAILURE!
        * Expected 'surTest' to equal 'suTest'.
      survey should be attached to scope
      should get a survey

    SurveyApp
      survey name should be provided
      survey should be attached to scope
      should get a survey

    Results: 6 specs, 1 failures, 0 pending

This blog has become quite lengthy, so I will stop here. Check out https://github.com/jasig/SurveyPortlet as a working example of Jasmine testing in a Maven project. I hope you find this information useful in your projects.

Benito Gonzalez

Benito Gonzalez

Senior Software Developer
Benito Gonzalez is a Software Architect at Unicon, Inc., a leading provider of education technology consulting and digital services. Benito joined Unicon in 2015 and has over 25 years of professional IT experience. He holds a Bachelor of Science degree in Computer Science. Benito has been active in web development since 1999. Benito spent six years prior to joining Unicon as the Enterprise Web Applications Manager at University of California, Merced, where he managed several campus-wide services, such as CAS, uPortal and Sakai CLE.
Top