Automating tree and graph visualisation unit tests in Python and Django.

We deal with how to systematically test visualisations of tree and graph data structures in a frontend browser (rendered as SVG in JavaScript) from within a Python backend test framework.

Problem

We would like to define test cases within an existing unit test framework in Python (and optionally Django) so that we can programmatically make assertions on the frontend JavaScript runtime and its final rendered output.

The renderings are in the form of SVG, which is XML, and the layout algorithms require a DOM which supports SVG, including the getBBox method.

Solution

We will use the browser automation tool Selenium to manage the rendering browser from within our unit tests. For Django, we can use the Live Server Test Case. In order to avoid launching a user interface which we do not need for the purposes of these tests, we will use the PhantomJS driver for Selenium, which is a headless sandboxed WebKit browser with a DOM.

For representing and transporting tree and graph structures we will use the DOT notation, and for rendering we will use D3. A set of JavaScript libraries which can parse DOT notation, manipulate graphs, lay them out, and render them to SVG using D3 are: dagre, dagre-d3, graphlib, and graphlib-dot.

Generating the DOT representation can be achieved through several stable Python libraries, namely: graphviz, PyGraphviz, or pydot.

Individual assertions in our unit tests will be expressed using the (synchronous) arbitrary JavaScript evaluation function available on the sandboxed browser instance.

Implementation

We will create an abstract base test case for dealing with the Selenium driver, as well as providing utilities for injecting appropriate JavaScript libraries and inspecting the browser's console for messages. Furthermore, we will use Django's Static File Finders in order to supply the browser with an HTML container as well as JavaScript libraries, although this is not obligatory (see below).

Defining the Base Test Case

Assuming you have a setting for the PhantomJS executable in settings.PHANTOMJS_EXECUTABLE, which should default to "/usr/local/bin/phantomjs"

 1 from django.conf import settings
 2 from django.test import LiveServerTestCase
 3 from django.contrib.staticfiles import finders
 4 
 5 class VisualisationLiveTestCase(LiveServerTestCase):
 6 
 7     @classmethod
 8     def setUpClass(cls):
 9         # Selenium
10         from selenium.webdriver.phantomjs.webdriver import WebDriver
11         from selenium.webdriver.support.wait import WebDriverWait
12         # PhantomJS driver for Selenium
13         cls.driver = WebDriver(executable_path = settings.PHANTOMJS_EXECUTABLE)
14         # typical renderable pane dimensions for browser
15         cls.driver.set_window_size(1024, 768)
16         cls.wait  = WebDriverWait(cls.driver, timeout = 10)
17         super(VisualisationLiveTestCase, cls).setUpClass()
18 
19     @classmethod
20     def tearDownClass(cls):
21         # CAVEAT EMPTOR! quit must appear before call to superclass
22         cls.driver.quit()
23         super(VisualisationLiveTestCase, cls).tearDownClass()
24 
25     def setUp(self):
26         super(VisualisationLiveTestCase, self).setUp()
27         # clear any caches and so on
28         get_cache('default').clear()
29 
30     def tearDown(self):
31         # optionally if using transaction management and you don't need
32         # persistence across unit tests
33         # rollback()
34         super(VisualisationLiveTestCase, self).tearDown()
35 
36     def add_script(self, path):
37         """
38         Dynamically adds a <script> element with the given JavaScript file as
39         the 'src' attribute, loaded through the Django Static Files Framework.
40         Therefore, *path* is a relative path capable of being interpreted via
41         any of the installed Static File Finders.
42         """
43         resolved_path = finders.find(path, all = False)
44         script = """
45         var script = document.createElement('script');
46         script.setAttribute("type", "text/javascript");
47         script.setAttribute("src", "{path}");
48         document.getElementsByTagName("head")[0].appendChild(script);
49         """.format(path = resolved_path)
50         self.driver.execute_script(script)
51 
52     @property
53     def console(self):
54         """
55         Obtains the browser's console which contains all messages sent to it.
56         """
57         return self.driver.get_log('browser')

Defining A Concrete Test Case

We can now use the above base test case to create test cases with specific combinations of JavaScript libraries as well as base rendering HTML containers.

 1 class TreeVisualisationLiveTestCase(VisualisationLiveTestCase):
 2 
 3     def setUp(self, *args, **kwargs):
 4         super(TreeVisualisationLiveTestCase, self).setUp(*args, **kwargs)
 5         # relative path to static HTML file used for rendering container
 6         self.driver.get(finders.find('test/tree.html', all = False))
 7         # the required JavaScript libraries
 8         self.add_script('js/d3/3.4.8/production/d3.min.js')
 9         self.add_script('js/dagre/0.1.0/debug/dagre.js')
10         self.add_script('js/dagre/d3/0.1.5/debug/dagre-d3.js')
11         self.add_script('js/graphlib/0.7.4/debug/graphlib.js')
12         self.add_script('js/graphlib/dot/0.4.10/debug/graphlib-dot.js')

Of course, it is perfectly possible to define the JavaScript libraries in the HTML container, or use an entirely different deployment method. The test case could also run in online simulated mode, by means of serving the HTML view through Django as you would normally do.

We can now add assertions which inspect the return values of ad hoc JavaScript evaluations within the sandboxed browser. In the following example we query the nodes of the tree and verify their existence through their labels. Notice that the types of values are preserved between JavaScript and Python.

    def test_dot_simple(self):
        dot = """digraph { 1; 2; 1 -> 2 [label=\\"label\\"] }"""
        function = """return graphlibDot.parse("{dot}").nodes()""".format(
            dot = dot)
        self.assertEqual(["1", "2", ], self.driver.execute_script(function))

A more involved test relies on the existence of a script in the HTML container (or other JavaScript library included somehow) which provides the function run(dot) expecting a DOT string and returning the width and height of the SVG canvas after the tree has been layed out and rendered.

    def test_dot_complex(self):
        dot = """
        // The graph name and the semicolons are optional
        graph graphname {
            a -- b -- c
            b -- d
        }
        """
        # invoke run(dot) and pass it the above dot string as its argument
        result = self.driver.execute_script("return run(arguments[0]);", dot)
        # ensure the rendered SVG has the anticipated dimensions
        self.assertEqual(result, {u'width': u'146', u'height': u'208'})
        # ensure the correct messages were printed to the console in the browser
        self.assertTrue('run' in self.console[0]['message'])
        self.assertTrue('done' in self.console[1]['message'])

Our included JavaScript below also sends two messages to the console, which we can check through assertions as well.

var run = function run(dot) {
    console.info("running");
    var svg = d3.select('svg');
    var graph = graphlibDot.parse(dot);
    var renderer = new dagreD3.Renderer();
    var layoutGraph = renderer.run(graph,
        svg.append('g').attr('transform', 'translate(20, 20)'));
    svg
    .attr('width', layoutGraph.graph().width + 40)
    .attr('height', layoutGraph.graph().height + 40);
    console.info("done");
    return { width: svg.attr('width'), height: svg.attr('height') };
};

Our HTML container simply features the following SVG element.

<svg id="svg" width="800" height="600"></svg>

Visually Inspecting

We can generate an effective screen capture of the browser's rendering in order to visually inspect the output of the unit test.

self.driver.save_screenshot('filename.png')

Screen capture of headless browser's rendered tree from the example code.

Alternatives

Instead of DOT notation we can send JSON representations of the trees and graphs; this will remove the DOT generation dependencies in the Python backend, as well as the DOT parsing dependencies in the JavaScript frontend, but at the cost of having to define an ad hoc JSON representation for the structures.

SVG can be generated (asynchronously) at the backend using a variety of tools, and then either embedded in the HTML response, loaded independently and injected in the rendered DOM, or converted into a binary image format and loaded as such. This removes all rendering dependencies from the frontend JavaScript, at the cost of deployment and implementation dependencies in the backend. Benefits of this approach include sharable caching of rendered output, as well as efficiency for small mobile devices which may exhibit an unacceptable lag when rendering complex layouts.

Caveats

Attempting to use any SVG rendering logic which relies on getBBox will fail in standalone node.js as it does not feature a DOM, and using a pluggable DOM such as jsdom will not fix the issue as it does not support the getBBox method.

Using standalone PhantomJS through sub-process communication from Python instead of a structured approach as with Selenium dramatically increases the complexity of testing and of deploying the test suite.

Conclusion

Given the programmatic testability of the entire pipeline starting from a Django view, through serialization of the data structures in DOT notation or otherwise, to layout and rendering into SVG on the frontend, we should always automate their testing and not rely on manual visual inspections of browser UI's.

Extensions

It should be possible to run a unit tast framework within the frontend JavaScript and integrate its output with the currently executing unit test controlling the frontend sandbox, instead of, or in addition to, assertions relying on ad hoc JavaScript evaluation in the sandbox.

Acknowledgements

Gratitude to Dr Felix Effenberger and Chris Pettitt for their feedback.

Comments

comments powered by Disqus