angular-charts is a small library of native Angular SVG chart directives that I’ve worked on when I can over the last year or so.
I’ve recently moved to testing the visual state of the directives with Protractor and screenshots.
This is the third type of testing I’ve used. In this post I will explain why it is the best yet!
Background
To give some background I’ll discuss the first two types and the problems I experienced.
Controller unit tests
People often immitate those they admire and I was no different, although it was an open-source project rather than a person. I based the testing of angular-charts on the angular-ui bootstrap directives.
The logic for creating arcs (piechart slices) and points (linechart series) is contained in directive controllers. This is great for decoupling logic from presentation, enabling TDD as a result.
An early Jasmine test was similar to the following:
describe('Controller: PiechartController', function() {
var ctrl;
var slice1 = { value: 50 };
var slice2 = { value: 150 };
beforeEach(function() {
module('piechart');
inject(function($controller, $rootScope) {
ctrl = $controller('PiechartController', {
$scope: $rootScope,
$attrs: {
radius: 100
}
});
ctrl.addSlice(slice1);
ctrl.addSlice(slice2);
});
});
it('setArcs should set start and end points', function() {
// expected co-ordinates based on radius
// passed into $controller
var ninety = { x: 100, y: 0 };
var oneEighty = { x: 0, y: 100 };
ctrl.setArcs();
expect(slice1.arc.start).toEqual(ninety);
expect(slice1.arc.end).toEqual(oneEighty);
expect(slice2.arc.start).toEqual(oneEighty);
expect(slice2.arc.end).toEqual(ninety);
});
it('setArcs should set large flag', function() {
ctrl.setArcs();
expect(slice1.arc.large).toBeFalsy();
expect(slice2.arc.large).toBeTruthy();
});
});
This type of test helped figure out how to create the SVG shape data, without the added complexity of directives and isolated/transcluded scopes etc. However it was coupled to the implementation in two ways:
- SVG markup, for example I changed to unit circle based piechart slices that were scaled up as a group by the radius value. The co-ordinates in every test needed updating which was prone to error.
- Directive structure, if I move away from controllers the tests would need refactoring.
HTML fragment tests
To cover the directive and scope complexity, I used tests that compiled HTML fragments and applied a scope object. An example is as follows:
describe('Directive: linechartSeries', function() {
var $compile, $rootScope;
var findPolylines = function(element) {
// caught me out initially!
// need to use the SVG namespace
return element[0].getElementsByTagNameNS(
'https://www.w3.org/2000/svg',
'polyline'
);
};
beforeEach(function() {
module('linechart');
inject(function(_$compile_, _$rootScope_) {
$compile = _$compile_;
$rootScope = _$rootScope_;
});
});
it('should create polyline elements', function() {
var element = angular.element(
'<div>' +
'<linechart h="100" w="100">' +
'<linechart-series' +
' ng-repeat="foo in foos"' +
' values="{{foo.values}}">' +
'</linechart-series>' +
'</linechart>' +
'</div>'
);
$compile(element)($rootScope);
$rootScope.foos = [
{ values: [1, 2, 1] },
{ values: [2, 1, 2] },
{ values: [4] }
];
$rootScope.$apply();
var polylines = findPolylines(element);
expect(polylines.length).toEqual(3);
// based on height and width attrs
expect(polylines[0].getAttribute('points'))
.toEqual('0,25 50,50 100,25');
expect(polylines[1].getAttribute('points'))
.toEqual('0,50 50,25 100,50');
expect(polylines[2].getAttribute('points'))
.toEqual('0,100');
});
});
HTML fragment tests reduced coupling to just the SVG markup. They were of course coupled to the API, for example linechart-series
nested within linechart
.
After using both types of test my biggest problem was asserting SVG markup. I never felt comfortable relying on it and would often want a visual check, after all it’s Scalable Vector Graphics.
It took some experimentation but I wanted a testing process that allowed me to ask “Does it still look the same?”.
Enter Protractor and screenshots.
Testing visual state
Protractor is an end-to-end testing framework built on top of WebDriverJS and Selenium. It’s worth reviewing How It Works for an introduction.
On first inspection it’s main benefit beyond WebDriverJS is the simple yet powerful addition of Angular specific selectors. For example element(by.repeater('foo in foos'))
is Protractor, findElement(By.name('btnFoo'))
is WebDriverJS.
Protractor
Rather than end-to-end testing of an application, I’m using protractor to:
- Take screenshots of static HTML pages containing the minimum SVG markup required to generate an expected result.
-
Drive and take screenshots of an angular-charts test harness to generate an actual result.
-
Compare the base 64 screenshot strings and pass or fail accordingly.
It’s important to note that the SVG markup between steps 1 and 2 can be entirely different, I don’t care as long as they look the same. For example, a piechart with one slice could be a simple circle
element in step 1, but in step 2 be a path
element that draws a circle.
I’ll explain how these steps work.
A static HTML page to assert against would contain:
<!-- two_slices_25_75.html -->
<div>
<svg height="210" width="210">
<g transform="translate(100,100), scale(100)"
stroke-width="0.01" stroke="white">
<path d="M0,0L1,0A1,1,1,0,1,0,1Z"></path>
<path d="M0,0L0,1A1,1,1,1,1,1,0Z"></path>
</g>
</svg>
</div>
The test harness to expect with would contain:
<!-- harness.html -->
<div ng-controller="HarnessCtrl">
<piechart radius="100">
<piechart-slice ng-repeat="slice in slices"
value="{{slice.value}}" stroke="white">
</piechart-slice>
</piechart>
</div>
The harness needs a way to push onto the slices
array of HarnessCtrl
. A button could be clicked with Protractor to do it, but this would entirely change the base 64 screenshot string. So the harness makes judicious use of driver.executeScript
to push to a global slicesValue
array as follows:
var sliceValues = []; // i.e. window.sliceValues.push(50)
angular.module('piechartHarness', ['piechart'])
.factory('wrapMethod', function() {
return function(object, method, wrapper) {
var fn = object[method];
return object[method] = function() {
return wrapper.apply(this, [fn.bind(this)].concat(
Array.prototype.slice.call(arguments))
);
};
}
})
.controller('HarnessCtrl', function ($scope, wrapMethod) {
$scope.slices = [];
// wrap the push method of sliceValues
// do something before and/or after it
wrapMethod(sliceValues, 'push', function(originalPush, value) {
originalPush(value);
$scope.slices.push({ value: value });
// manually digest as change is external to Angular
$scope.$digest();
});
});
Finally a Jasmine test to put it all together as follows:
describe('piechart directive', function() {
it('should render', function() {
var baseUrl = 'https://localhost:8000/test/piechart/';
// navigate to a connect server for the harness and static content
browser.get(baseUrl + 'harness.html');
browser.executeScript('sliceValues.push(25)');
browser.executeScript('sliceValues.push(75)');
browser.takeScreenshot().then(function(actual) {
// no Angular on static pages so use underlying driver
browser.driver.get(baseUrl +
'expected/two_slices_25_75.html');
browser.driver.takeScreenshot().then(function(expected) {
expect(expected).toEqual(actual);
});
});
});
});
And that’s it! I’m really happy that a simple idea has been made possible with Protractor.
However…
Teething problems
It’s slow
Despite my very simple usage of Protractor (navigate to a harness, execute script, take a screenshot, navigate to a static page, take a screenshot, compare) it’s still slow. Just navigating pages is slow.
We can counter this by getting all the screenshots we need for a suite once at the start. This sounds obvious, but I started out switching between static pages and the harness too much for anything more than a couple of tests.
This is possible with a combination of Jasmine’s beforeAll
function and the Node async library as follows:
var async = require('async');
describe('piechart directive', function() {
var baseUrl = 'https://localhost:8000/test/piechart/';
var harnessUrl = baseUrl + 'harness.html';
var two_slices_50_50, three_slices_50_50_100;
var getDriverScreenshot = function(url, callback) {
browser.driver.get(url);
browser.driver.takeScreenshot().then(function(data) {
callback(null, data);
});
};
beforeAll(function(done) {
// one at a time unfortunately
async.series([
// avoiding repetition
async.apply(getDriverScreenshot,
baseUrl + 'expected/two_slices_50_50.html'),
async.apply(getDriverScreenshot,
baseUrl + 'expected/three_slices_50_50_100.html')
], function(err, results) {
two_slices_50_50 = results[0];
three_slices_50_50_100 = results[1];
// tell Jasmine we are done setting up the suite
done();
});
});
it('...');
it('...');
});
Lack of autowatching
I haven’t found a way to autowatch. It would be great if whenever I changed a source or spec file angular-charts.js was automatically rebuilt and Protractor re-ran tests. However, this would deviate from Protractor’s intended purpose (end-to-end application testing) and in my experience it’s generally not a good idea to bend technologies to your will too much!
Conclusion
A quick demonstration follows, which uses sleep to slow things down.
Whilst writing this post I’ve thought hard about an appropriate mix of test types. I only want to change tests as behaviour changes, not implementation.
- Controller tests, whilst useful during development, are out.
-
HTML fragment tests will remain but without assertions against SVG attributes. They will fulfil my autowatching requirement.
-
Protractor tests will run on demand with high coverage – as long as I can keep them relatively quick.
CI (currently Travis) will run HTML fragment and Protractor tests.