Sunday, December 3, 2017

An AngularJS Dashboard, Part 9: Unit Tests

NOTE: for best results, view the http: version of this page (else you won't get syntax highlighting).

This is Part 9 in a series on creating a dashboard in AngularJS. I'm blogging as I progressively create the dashboard, refining my Angular experience along the way. An online demo of the latest work is available here.

Previously in Part 8, we added role support and improved the mobile experience. Today, we're going to add unit tests for our AngularJS code using Jasmine. Unit testing of code is highly important, and Angular cites testability as one of its core principles.

Here's what we'll be ending up with:

Unit Testing

We've wanted to have unit tests all along during this series, but ran into many problems getting them working. I've shared in the past that one of the frustrating things about Angular is how much surface area is exposed in the framework and how many different ways there are of doing something; but that problem is multiplied tenfold when it comes to unit testing a component. I struggled for many weeks to find the right combination of code in my tests that would instantiate my component and test its functions. The good news is, I'm finally there and can at last tackle the subject in today's post.

On the AngularJS web site (angularjs.org), two tools are listed as recommended tools for unit testing: Karma and Jasmine. After some research, I settled on Jasmine (https://jasmine.github.io/).

Visual Studio Integration

The next step was to make it possible to have my Jasmine tests run in Visual Studio's Test Explorer. This can be achieved by going to Visual Studio's Tools and Extensions menu and installing Chutzpah. You can read up on Chutzpah here.

Chutzpah in Visual Studio Extensions and Updates

With Chutzpah installed, the latest Dashboard project code will now show tests in Visual Studio's Test Explorer. You'll notice that there are a great many tests. The idea is to test every property and function in our component's controller. The tests themselves are mostly very simple, as we shall see.

Dashboard project with Jasmine tests in Visual Studio

To run the tests, right-click Module : [ component : dashboard ] and select Run Selected Tests.

Running All Tests

Soon afterward, the test results will be displayed. All of the tests should have passed, and should show in green.

All Tests Passed

Test Setup

The convention encouraged by the AngularJS team is to name your tests after your component / controller / service files with the file type '.spec' inserted. In our dashboard project, the tests are in the file named dashboardController.spec.js.

References

In our spec file, we begin with a special section of reference comments. These lines are processed by Jasmine and cause needed JavaScript files to be loaded--they kind of serve the same purpose as script tags in an HTML page. You'll find that many of the .js files loaded in the project's index.html page are reproduced here.
/// <reference path="../Scripts/es6-promise.auto.min.js" />
/// <reference path="../Scripts/jquery-3.2.1.min.js" />
/// <reference path="../Scripts/Scripts/jquery-ui.min.js" />
/// <reference path="../Scripts/jquery-ui.touch-punch.js" />
/// <reference path="../Scripts/spectrum.js" />
/// <reference path="../Scripts/toastr.js" />
/// <reference path="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js" />
/// <reference path="../Scripts/angular.js" />
/// <reference path="../Scripts/angular-mocks.js" />
/// <reference path="../app/app.module.js" />
/// <reference path="../components/dashboard/google.chart.service.js" />
/// <reference path=""https://www.gstatic.com/charts/loader.js" />
/// <reference path="../components/dashboard/demo.data.service.js" />
/// <reference path="../components/dashboard/dashboard.component.js" />
References

Note that we are referencing our  canned demo data service in our references, not the sql data service. It is a common practice in AngularJS unit testing to mock out services. Our already-existing demo data service will serve this purpose.

Support Functions

The next section includes some support functions. These will be used to load the controller's html template.
// Dashboard component unit tests

// test support functions

function httpGetSync(filePath) {
    var xhr = new XMLHttpRequest();
    xhr.open("GET", filePath, false);
    xhr.send();
    return xhr.responseText;
}

function preloadTemplate(path) {
    return inject(function ($templateCache) {
        var response = httpGetSync(path);
        $templateCache.put(path, response);
    });
}
Support Functions

Describe Block and Template Compilation

All of our tests are enclosed in a describe block.
describe('component: dashboard', function () {
    var $rootScope = null;
    var element, scope;
    var ChartService, httpBackend;

    beforeEach(module('dashboardApp'));
    beforeEach(module('dashboard'));
    beforeEach(preloadTemplate('/components/dashboard/dashboard.template.html'));

    var ChartService, DataService, http, controller, $ctrl;

    beforeEach(inject(function (_$rootScope_, _$compile_, $injector) {

        $compile = _$compile_;
        $rootScope = _$rootScope_;

        scope = $rootScope.$new();

        element = angular.element('<html><head><title>Dashboard</title><meta charset="utf-8" /><link rel="icon" href="data:;base64,iVBORw0KGgo="><link href="https://fonts.googleapis.com/css?family=Roboto" rel="stylesheet"><link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css"><link href="http://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet" /><link href="Content/css/dashboard.css" rel="stylesheet" /><link href="Content/css/toastr.css" rel="stylesheet" /><link href="Content/css/spectrum.css" rel="stylesheet" />' +
            '<body><div style="width: 1920px"><dashboard id="dashboard"></dashboard></div></body></html>');

        $compile(element)(scope);
        scope.$digest();
        $ctrl = scope.$$childHead.$ctrl;
    }));

    ..tests...
});
describe block

The describe statement gives us the category name we saw earlier in Visual Studio Test Explorer. Within it, are several beforeEach statements. The first two declare our dashboard app and module. The third loads the controller's HTML template file. The fourth encloses an inject statement.

The inject statement injects dependencies. To someone doing their first ever AngularJS project, I find inject kind of fascinating: there are dozens of objects you can pass to it as parameters, and you seem to have great freedom in what you include. We are passing a number of JS objects and functions important to setting up the test: the AngularJS root scope $rootScope$, its $compile  object, and the $injector object. Notice that the injector expects surrounding underscores in some of the names. This is apparently a convention in AngularJS testing. We save these values in variables for later use.

Next, we compile our HTML. Usually our project has an index.html page which AngularJS renders into. In our test, we have a similar fragment of HTML assigned to the variable element. The $compile function is used compile the element and set up a scope. Then, scope.$digest() is called to perform an AngularJS digest cycle. Lastly, the controller of the component is assigned to the variable $ctrl. If all of this sounds a bit complex and non-obvious, it was! It took many, many frustrating weeks before I found the right combination of code that would work.

The Tests

And now, we can discuss the tests themselves. Below are the first few, which test controller properties. Notice that these tests are self-documenting: the it(...) function's first parameter is a description of what is being tested; these are where the test names came from that we saw earlier in Visual Studio Test Explorer. The second parameter is a function to run. The function can do whatever it needs to, but in our case we are mostly concerned with verifying properties in the controller contain expected values. We use the expect statement and a condition clause such as .toContain or .toEqual to check a property against an expected value.

    // These tests confirm the controller contains expected properties 

    it('$ctrl.title has expected value', function () {
        expect($ctrl.title).toContain('Dashboard');
    });

    it('$ctrl.chartProvider has expected value', function () {
        expect($ctrl.chartProvider).toContain('Google');
    });

    it('$ctrl.dataProvider has expected value', function () {
        expect($ctrl.dataProvider).toContain('Demo');
    });

    it('$ctrl.tilesacross has expected value', function () {
        var expectedTilesAcross = 8;
        expect($ctrl.tilesacross).toEqual(expectedTilesAcross);
    });

Property tests

Further down in the spec file are tests that invoke controller functions. We can access properties and functions in our controller via the $ctrl object. As with the property tests, we use expect to verify the results are correct.
     it('$ctrl.moveTileUp(id) to swap tiles', function () {
         var title1 = $ctrl.tiles[0].title;
         var title2 = $ctrl.tiles[1].title;
         $ctrl.moveTileUp('2');
         expect($ctrl.tiles[0].title).toEqual(title2);
         expect($ctrl.tiles[1].title).toEqual(title1);
     });

     it('$ctrl.moveTileDown(id) to swap tiles', function () {
         var title1 = $ctrl.tiles[0].title;
         var title2 = $ctrl.tiles[1].title;
         $ctrl.moveTileDown('1');
         expect($ctrl.tiles[0].title).toEqual(title2);
         expect($ctrl.tiles[1].title).toEqual(title1);
     });

     it('$ctrl.removeTile(id) to remove tile | resetDashboard to restore tile', function () {
         var length = $ctrl.tiles.length;
         $ctrl.removeTile('1');
         expect($ctrl.tiles.length).toEqual(length - 1);
         $ctrl.resetDashboard();
         expect($ctrl.tiles.length).toEqual(length); 
     });
Function tests

Promises, Promises

One very big problem I had getting my tests working had to do with JavaScript promises. In our controller, we normally use the Promise operation to invoke service functions, which may or may not be asynchronous. It turns out Jasmine and AngularJS together don't innately support promises.

My first attempt to resolve this issue was to add a Promise polyfill as a reference. Unfortunately, this didn't solve anything and tests were still failing.

To ultimately combat this problem, I added a flag to the data service named requiresPromise. It is true in the sql data service (which makes Ajax calls to the MVC controller), and false in the demo data service (which simply returns objects). With this testable flag in place, the controller code that used promises is now bypassed. It's a bit disheartening that I had to code around this issue, but I have yet to find a better solution.

Summary

Today we added unit tests, written in Jasmine, integrated with Visual Studio Test Explorer. We have written tests for the vast majority of properties and functions in the controller.

Download Code
Dashboard_08.zip
https://drive.google.com/open?id=11bej1Wf_YmqqW0Saed-J0SfTy2ERR1Qu

No comments: