Live Webinar
Troubleshooting Digital Experiences Across Owned and Unowned Networks

Engineering

Integrating and Testing Reusable D3 Components in an AngularJS Environment

By Adam Wilson
| | 41 min read

Summary


While there are many articles describing the D3 reusable component pattern, I’ve noticed that there isn’t much information on using or testing these components within an AngularJS setting.

In this post I’m going to briefly cover the D3 reusable component pattern, integrating these components into an Angular app and the advantages to doing so. Then we’ll go into testing these visual components using the Karma test runner.

There are a few advantages to using the D3 reusable component pattern:

  • It provides a clear separation of concerns between application logic and drawing logic, making your architecture easier to understand.
  • It eliminates the need to rewrite similar draw logic in multiple places in your application.
  • It makes your draw logic easy to test.

Let’s define a line chart component following the principles of this pattern.

d3.custom = (d3.custom || {});

// Data format
/*
    [ 
        [{ x: , y: }, { x: , y: }], Line One
        [{ x: , y: }, { x: , y: }]  Line Two
    ]
*/

d3.custom.lineChart = function() {

    // Private Variables
    var chartWidth  = 400;
    var chartHeight = 200;

    var xAccessor = function(data) { return data.x; };
    var yAccessor = function(data) { return data.y; }; 

    var xScale     = d3.scale.linear();
    var yScale     = d3.scale.linear();
    var colorScale = d3.scale.category10();

    var line = d3.svg.line().x(X).y(Y);


    function chart(selection) {

        selection.each(function(data) {

            // Update x scale
            xScale
            .domain(getXDomain(data))
            .range([0, chartWidth]);

            // Update y scale
            yScale
            .domain([0, getYMax(data)])
            .range([chartHeight, 0]);


            var chartContainerData = d3.select(this).selectAll('svg.chartContainer')
                                    .data([data]);

            // Create containers if they don't exist
            var chartContainer = chartContainerData.enter()
                                .append('svg')
                                .attr('class', 'chartContainer');

            var lineContainer  = chartContainer
                                .append('g')
                                .attr('class', 'lineContainer');

            if (chartContainer.empty()) {
                chartContainer = d3.select(this).selectAll('svg.chartContainer');

                lineContainer  = chartContainer.selectAll('g.lineContainer');
            }

            chartContainer.attr('width',  chartWidth)
                          .attr('height', chartHeight); 


            // Perform the data join
            var lineData = lineContainer.selectAll('path.line')
                            .data(data);

            // Update
            lineData.attr('d', line);

            // Enter
            lineData.enter()
                .append('path')
                    .attr('class', 'line')
                    .attr('d', line)
                    .attr('fill', 'none')
                    .attr('stroke', function(data, index) {
                        return colorScale(index);
                    });

            // Exit
            lineData.exit()
                    .remove();
        });
    }

    // Utility methods for generating scales
    function getXDomain(data) {

        var xValues = [];

        data.forEach(function(lineData) {
            lineData.forEach(function(lineValue) {
                var xValue = xAccessor(lineValue);
                xValues.push(xValue);
            });
        });

        return d3.extent(xValues);
    }

    function getYMax(data) {
        var yMax = d3.max(data, function(lineData) {
            return d3.max(lineData, yAccessor);
        });
        
        return yMax;       
    }

    // Accessor functions for our line
    function X(data) {
        var xValue = xAccessor(data);
        return xScale(xValue);
    }

    function Y(data) {
        var yValue = yAccessor(data);
        return yScale(yValue);
    }

    // Public Variables/ (Getters and Setters)
   chart.width = function(newWidth) {
        if (!arguments.length) return chartWidth;
        chartWidth = newWidth;
        
        return this;
    };

    chart.height = function(newHeight) {
        if (!arguments.length) return chartHeight;
        chartHeight = newHeight;
        
        return this;
    };


    chart.xAccessor = function(newXAccessor) {
        if (!arguments.length) return xAccessor;
        xAccessor = newXAccessor;

        return this;
    };

    chart.yAccessor = function(newYAccessor) {
        if (!arguments.length) return yAccessor;
        yAccessor = newYAccessor;

        return this;
    };

    return chart;
};

Then it would be used like:

var lineChartData = [
    [{x: 0, y: 0}, {x: 1, y: 1}, { x: 2, y: 2 }], // Line one
    [{x: 0, y: 1}, {x: 1, y: 3}, { x: 2, y: 0 }]  // Line two
 ];

var lineChartLayout = d3.custom.lineChart();

d3.select(chartContainerElement)
.datum(lineChartData)
.call(lineChartLayout);

Which gives us this:

D3 reusable component example

Simple enough right? Of course in the real world you would probably want to add axes and other additional functionality, but this should be enough to start testing against.

While our line chart can be used in an Angular app in this form, it isn’t ideal. It violates the principle of dependency injection by exposing a global object throughout your application and has no way to interact directly with Angular code. Now what if you had common logic between multiple charts and wanted to share this via an Angular service? With a simple modification we can make this possible.

angular.module('customCharts', ['customCharts.line'])
.service('chartService', function(lineChart) {

   return {
        lineChart: lineChart
   };

});

angular.module('customCharts.line', [])
.factory('lineChart', function() {

    // Data format
    /*
        [ 
            [{ x: , y: }, { x: , y: }], Line One
            [{ x: , y: }, { x: , y: }]  Line Two
        ]
    */
    return function() {
      // The same as our original lineChart function
        //...
    }
});

In addition to the lineChart service itself, note that I also defined a “wrapper” service, chartService, to give our chart a namespace for readability purposes. This is, of course, up to personal preference, as we could have just injected the lineChart function directly as well.

This can be used similarly to the non-angular version:

var lineChartData = [
    [{x: 0, y: 0}, {x: 1, y: 1}, { x: 2, y: 2 }], // Line one
    [{x: 0, y: 1}, {x: 1, y: 3}, { x: 2, y: 0 }]  // Line two
 ];
var lineChartLayout = chartService.lineChart();

d3.select(chartContainerElement)
.datum(lineChartData)
.call(lineChartLayout)

Testing

While the benefits to writing unit tests should be obvious, it is a practice that still seems to be the exception rather than the norm. This great Stack Overflow response does a good job detailing the main benefits of testing.

To summarize the response and add to the key points of why testing is so important:

  • Writing code in such a way that makes it testable also enforces an architecture that is more loosely coupled and reusable.
  • You can catch runtime errors immediately upon changing code.
  • Having a suite of unit tests reduces the number of errors introduced due to refactoring.
  • Your fellow developers can more easily understand the purpose of a segment of code by reading the expected functionality from its supporting test.

The gold standard for unit testing angular code is Karma. Karma was created and is maintained by the core Angular team and provides many utilities that make it easy to test angular apps. We will be using the Jasmine syntax with our Karma tests.

Testing our D3 Component

Now that we have our reusable d3 component integrated into Angular, let’s discuss some of the advantages that this brings. We can now move any shared or non drawing specific logic into separate services.

Take our scale setup logic for instance, the getYMax and getXDomain functions that are currently internal to our line chart:

angular.module('customCharts.scaleSetup', [])
.service('scaleSetupService', function() {

    // This service provides utility methods to operate on nested arrays
    // Ex:
    // [
    //   [{ x: 0, y: 5 }, { x: 1, y: 2 }],
    //   [{ x: 0, y: 1 }, { x: 1, y: 3 }]
    // ]

    return {
        getDomain:   getDomain,
        getMaxValue: getMaxValue
    };


    // Gets the extent of the values returned from the accessor
    // function in the dataset
    function getDomain(dataset, valueAccessor) {

        var dataPointValues = [];
        dataset.forEach(function(dataArray) {
            dataArray.forEach(function(dataPoint) {
                var value = valueAccessor(dataPoint);
                dataPointValues.push(value);
            });
        });

        return d3.extent(dataPointValues);
    }

    // Gets the max value returned from the accessor function 
    // in the dataset
    function getMaxValue(dataset, valueAccessor) {

        var maxValue = d3.max(dataset, function(dataArray) {
            return d3.max(dataArray, valueAccessor);
        });
        
        return maxValue;       
    }

});

We can now inject this service back into our Angularized lineChart (and any other similarly structured chart!) component and use this in place of our internal getXDomain and getYMax functions:

angular.module('lineChart', ['customCharts.scaleSetup'])
.factory('lineChart', function(scaleSetupService) {
  //...
});

We are now able to write tests specific to this logic, and alter our drawing logic without worrying about interfering with our scale setup logic.

describe('scaleSetupService', function() {
    
    var scaleSetupService;
    var dataset;
    var xAccessor = function(data) { return data.x; }; 
    var yAccessor = function(data) { return data.y; };

    beforeEach(module('customCharts.scaleSetup'));
    beforeEach(inject(function($injector) {
        scaleSetupService = $injector.get('scaleSetupService');

        dataset = [
                    [{x: 0, y: 5}, {x: 1, y: 3}],
                    [{x: 1, y: 2}, {x: 2, y: 0}]
                  ];
    }));


    it('should, given a nested array, get the domain', function() {
        
        var expectedResult = [0, 2];
        var actualResult = scaleSetupService.getDomain(dataset, xAccessor);
        expect(actualResult).toEqual(expectedResult);
    });

    it ('should, given a nested array, get the max value', function() {

        var expectedResult = 5;
        var actualResult = scaleSetupService.getMaxValue(dataset, yAccessor);
        expect(actualResult).toEqual(expectedResult);
    });

});

Now that we have our drawing logic completely isolated, how do we automate the testing of something that is visual? While this does limit us to a certain extent, the fact that Karma executes our tests in the standard browsers with access to the DOM means that we can actually test against the DOM elements. Normally your unit tests should not be aware of implementation details of the tested code, but for testing visual components we have to break this convention slightly. Because our chart assigns different elements (such as the line container, axis container etc) classes, we can select these elements both to see if they exist and to get access to the bound data.

This is the code we are going to use to setup our tests:

var chartService;
var lineChart;
var dataset;
var chartContainer;

beforeEach(module('customCharts.line'));
beforeEach(module('customCharts'));
beforeEach(inject(function($injector) {
    dataset = [
                 [{ x: 0, y: 0 }, { x: 1, y: 1 }, { x: 2, y: 2 }]
               ];

    chartService = $injector.get(‘chartService’);
    lineChart    = chartService.lineChart();
    chartContainer = d3.select('body')
                        .append('div')
                        .attr('class', 'testContainer');
}));

// We want a fresh chart container after every test
afterEach(function() {
    chartContainer.remove();
});

How about a simple test to ensure that the exposed getters and setters can be used to update the layout?

it('should provide getters and setters', function() {
    var defaultChartWidth  = lineChart.width();
    var defaultChartHeight = lineChart.height(); 

    lineChart.width(100)
             .height(50);

    var newChartWidth  = lineChart.width();
    var newChartHeight = lineChart.height();


    expect(defaultChartWidth).not.toBe(100);
    expect(defaultChartHeight).not.toBe(50);
    expect(newChartWidth).toBe(100);
    expect(newChartHeight).toBe(50);
});

This unit test is about as standard as you can get, but what about a test covering the generated DOM components?

it('should render a chart with minimal requirements', function() {

    chartContainer.datum(dataset)
                  .call(lineChart);

    var lineContainer = chartContainer.selectAll('svg.chartContainer');
    var line          = lineContainer.selectAll('path.line');

    expect(lineContainer.empty()).not.toBe(true);
    expect(line.empty()).not.toBe(true);
});

Since our line chart creates a container element (an svg with the class chartContainer), which contains our line (a path with the class line), we can test against these elements to see if they were added successfully.

If our getter/setter test passes, we can be confident that updates to our chart layout settings will persist, however this doesn’t ensure that future charts will actually use these updated values.

it('should redraw the chart with updated attributes', function() {
    lineChart.width(100);
    
    chartContainer.datum(dataset)
                  .call(lineChart);

    lineChart.width(200);
    chartContainer.call(lineChart);

    var lineChartContainer      = chartContainer.select('svg.chartContainer');
    var lineChartContainerWidth = parseInt(lineChartContainer.attr('width'));
    expect(lineChartContainerWidth).not.toEqual(100);
    expect(lineChartContainerWidth).toEqual(200);
});

Now we can be confident that our chart will redraw correctly based on any updates to our configuration. Of course we are just checking that the width is being updated correctly. You should add any additional properties here that you want to test.

We know that our chart will update in response to configuration changes, but more importantly, we want to be sure that it will actually draw with the data being passed.

it('should update a chart with new data', function() {
    chartContainer.datum(dataset)
                  .call(lineChart);

    var firstChartDataset = chartContainer.select('g.lineContainer').datum();
    var firstChartLine    = chartContainer.selectAll('path.line');
    var firstLineDataset  = firstChartLine.datum();
    var firstLineData     = firstChartLine.attr('d');

    var secondDataset = [
                            [{ x: 0, y: 3  }, { x: 1, y: 2  }, { x: 2, y: 1  }]
                        ];

    chartContainer.datum(secondDataset)
                  .call(lineChart);

    var secondChartDataset  = chartContainer.select('g.lineContainer').datum();
    var secondChartLine     = chartContainer.selectAll('path.line');
    var secondLineDataset   = secondChartLine.datum();
    var secondLineData      = secondChartLine.attr('d');
    

    // Check if lineContainer data was updated
    expect(firstChartDataset).toBe(dataset);
    expect(secondChartDataset).toBe(secondDataset);

    // Check if lines themselves have been updated
    expect(firstLineDataset).toBe(dataset[0]);
    expect(secondLineDataset).toBe(secondDataset[0]);

    // Check if the actual drawing is different
    expect(firstLineData).not.toEqual(secondLineData);
});

Here we are testing that the data join is working properly by checking that the input data and bound chart data references are the same. We are also testing that the ‘d’ attribute is not the same as the previous drawing. This lets us know that the drawing did in fact change in response to the new data. This is generally good enough, but, if you really wanted to, you could use test data where you know what the ‘d’ string output should be and test against that.

Since we want our line chart layout to be flexible, we test that we can create more than one chart layout, and render charts unique to these different layouts. We want to make sure that each instance of our chart is completely isolated from the others, and that there are no unintended dependencies between the two (like if our selectors were not specific enough and changing one chart altered the other).

it('should render two charts with distinct configurations', function() {
    chartContainer.append('div')
                  .datum(dataset)
                  .call(lineChart);


    var secondDataset = [
        [{ x: 1, y: 3  }, { x: 2, y: 2  }, { x: 3, y: 1  }]
    ];
    var secondLineChart = chartService.lineChart()
                                      .width(20000)
                                      .height(10000);

    chartContainer.append('div')
                  .datum(secondDataset)
                  .call(secondLineChart);

    var charts = chartContainer.selectAll('.chartContainer');

    expect(charts.size()).toBe(2);
    expect(secondLineChart.width()).not.toBe(lineChart.width());
});

We also would like to test that we can draw more than one line at a time:

it('should draw a line for each line data array in the dataset', function() {
    var multiDataset = [ 
     [{ x: 1, y: 20}, { x: 2, y: 40 }, { x: 3, y: 10}, {x: 4, y: 35 }],
     [{ x: 1, y: 23}, { x: 2, y: 43 }, { x: 3, y: 13}, {x: 4, y: 38 }], 
     [{ x: 1, y: 26}, { x: 2, y: 46 }, { x: 3, y: 16}, {x: 4, y: 41 }]
   ];

    chartContainer.datum(multiDataset)
                  .call(lineChart);

    var lines = chartContainer
                .select('g.lineContainer')
                .selectAll('path.line');
                
    var lineOneData   = d3.select(lines[0][0]).datum();
    var lineTwoData   = d3.select(lines[0][1]).datum();
    var lineThreeData = d3.select(lines[0][2]).datum();

    expect(lines.size()).toBe(3);
    expect(lineOneData).toBe(multiDataset[0]);
    expect(lineTwoData).toBe(multiDataset[1]);
    expect(lineThreeData).toBe(multiDataset[2]);               
});

If we can draw multiple lines, we’d also like to be sure that we can redraw multiple lines correctly.

it('should redraw every line correctly when drawing with new data', function() {
    var firstMultiDataset = [ 
        [{ x: 1, y: 20}, { x: 2, y: 40 }, { x: 3, y: 10}, {x: 4, y: 35 }],
        [{ x: 1, y: 23}, { x: 2, y: 43 }, { x: 3, y: 13}, {x: 4, y: 38 }], 
        [{ x: 1, y: 26}, { x: 2, y: 46 }, { x: 3, y: 16}, {x: 4, y: 41 }]
    ];

    chartContainer.datum(firstMultiDataset)
                  .call(lineChart);

    var firstChartLines = chartContainer
                          .select('g.lineContainer')
                          .selectAll('path.line');

    var firstChartLineOneData   = d3.select(firstChartLines[0][0]).attr('d');
    var firstChartLineTwoData   = d3.select(firstChartLines[0][1]).attr('d');
    var firstChartLineThreeData = d3.select(firstChartLines[0][2]).attr('d');


    var secondMultiDataset = [ 
        [{ x: 1, y: 2  }, { x: 2, y: 4   }, { x: 3, y: 1},   {x: 4, y: 3.5 }],
        [{ x: 1, y: 2.3}, { x: 2, y: 4.3 }, { x: 3, y: 1.3}, {x: 4, y: 3.8 }], 
        [{ x: 1, y: 2.6}, { x: 2, y: 4.6 }, { x: 3, y: 1.6}, {x: 4, y: 4.1 }]
    ];

    chartContainer.datum(secondMultiDataset)
                  .call(lineChart);

    var secondChartLines = chartContainer
                           .select('g.lineContainer')
                           .selectAll('path.line');

    var secondChartLineOneData   = d3.select(secondChartLines[0][0]).attr('d');
    var secondChartLineTwoData   = d3.select(secondChartLines[0][1]).attr('d');
    var secondChartLineThreeData = d3.select(secondChartLines[0][2]).attr('d');


    expect(secondChartLines.size()).toBe(3);
    expect(firstChartLineOneData).not.toEqual(secondChartLineOneData);
    expect(firstChartLineTwoData).not.toEqual(secondChartLineTwoData);
    expect(firstChartLineThreeData).not.toEqual(secondChartLineThreeData);
});

Just like the single line test, we are checking against the ‘d’ attribute to see if it has been changed in response to the new data. Again, we could test against a known ‘d’ output if we needed our test to be tighter.

There are more tests included in the code samples on Github, so feel free to check them out. Different types of charts will require different types of tests, but these tests cover functionality that is core to almost any D3 chart. Now you can feel confident when refactoring or adding functionality to your chart. Shoot me any questions or comments below!

Subscribe to the ThousandEyes Blog

Stay connected with blog updates and outage reports delivered while they're still fresh.

Upgrade your browser to view our website properly.

Please download the latest version of Chrome, Firefox or Microsoft Edge.

More detail