Developing an OpenLayers app from scratch in ES6 using Mocha, Webpack and Karma: Mocha tests

9 minute read

… where we start testing our application.

Tentatively testing

Now that we’ve prepared our code for the modularisation to come, we should start thinking about using other good software engineering practices such as testing in our project. I find that getting testing up and running in a software project to be notoriously difficult in the beginning, but once one has a certain degree of momentum, then testing becomes much easier and more natural. I find it difficult because it’s not always obvious what to test at the beginning and that most of my initial tests are quite banal in nature. I’ve had to realise that these initial tests are like “trainer wheels” on a bike: they’re there to get you started and once you’ve got the hang of things, they can be removed. But still, there’s the question: what to test first? As soon as I pose this question to myself, I find it helps to sit back and think about what the smallest, most fundamental thing that would be testable would actually be. Often, I find this process to be iterative and that my first attempts at writing an initial test are not fundamental enough, or aren’t specific enough to make a good test. This isn’t a problem and is often a good process to go through because thinking is an important part of the process.

In the current project, one instinct might be to check if the map object is not undefined or not null or something like that. This isn’t a bad first step, however asking if a thing isn’t “nothing” isn’t as positive (or as specific) as asking if it is something. So how do we do that in the current code? We can see that a map object contains a view object and that the view has a center property which has a well-defined value. This is a good thing to test, because we’re testing a very explicit value, which makes the test condition easy to define. This has the positive side-effect that we’re also testing for the existence of a valid map object, which also satisfies our initial intuition of what to test. We can therefore formulate our initial test like this:

The array of the longitude and latitude of the centre attribute of the map’s view should equal [14.44, 50.07].

Let’s now turn this into code.

But first, a bit of infrastructure. It’s helpful to use a testing framework within which to define and run your tests. As with almost all programming languages, there are many to choose from, and I’ve chosen for this project the mocha test framework because it is mature and well established within the JavaScript programming community and it has support for many extensions. One of which is the chai assertion library, which we’ll use to describe how we assert that our expectations of the code are correct. I quite like the BDD-style of writing test assertions and hence I’m going to use the “should” style within chai.

Before we can write our test, we have to install the relevant dependencies:

$ npm install --save-dev mocha chai

By default, mocha expects test files to be within a directory called test, so let’s create that:

$ mkdir test

Now create a file within the test directory called map-test.js and add the following code:

import 'chai/register-should';

import map from '../src/js/index.js';

describe('Basic map', function () {
    it('should have a view centred on Prague', function () {
        map.getView().getCenter().should.deep.equal([14.44, 50.07]);
    });
});

Now we’ve got our first test! Yay!

This code deserves some explanation. The first line imports the chai assertion library and registers the should style so that we can say things like someObject.should.equal(someValue) which reads quite well and is also executable code (also nice to have). Then we import the map variable from our main code (note that we’ve not yet exported this variable, but we’ll come to that). We then define a test suite with a describe block; such a block describes the group of tests within the suite. The it() function then contains (and describes, via its own description text) the actual test to run, which is then an executable version of the test we described in words earlier.

To run the test suite, we can use the npx command again:

$ npx mocha

which will die horribly and give you a huge stacktrace. The most important part of the output is at the beginning:

/path/to/arctic-sea-ice-map/test/map-test.js:1
/home/cochrane/Projekte/PrivatProjekte/arctic-sea-ice-map/test/map-test.js:1
import 'chai/register-should';
^^^^^^

SyntaxError: Cannot use import statement outside a module

This is mocha trying to tell you that it doesn’t know about the import statement and hence it doesn’t know about ES6 modules. The fix for this is to use the esm module, which we now have to install via npm:

$ npm install --save-dev esm

and we have to tell mocha to use this module when running the tests:

$ npx mocha --require esm

Again, this will die horribly, however this time with a different stacktrace. Again, the first few lines hint at what the problem is and potentially how to solve it:

/path/to/arctic-sea-ice-map/node_modules/ol/ol.css:1
.ol-box {
^

SyntaxError: Unexpected token .

This is telling us that mocha doesn’t know about CSS files, which is fair enough because we’re trying to run JavaScript code here and not CSS. Therefore, we need to tell mocha to ignore CSS files, and to do that we need to install the ignore-styles module and tell mocha to use it:

$ npm install --save-dev ignore-styles
$ npx mocha --require esm --require ignore-styles

Guess what? It still barfs. But this time we find that a variable called window is not defined:

/path/to/arctic-sea-ice-map/node_modules/elm-pep/dist/elm-pep.js:1
ReferenceError: window is not defined

What does this mean? Well, note that we’re using node to run JavaScript tests and that JavaScript will ultimately run in a browser, and a browser will always have a window object defined in its global namespace. So how do we trick node (a browser-less environment) into thinking that it has such a variable? Enter jsdom, a JavaScript version of the browser’s DOM. We need this to work in the global namespace that mocha will be working in so we need to install jsdom and jsdom-global:

$ npm install --save-dev jsdom jsdom-global

And we can try running the tests again; this time we also have to tell mocha about jsdom-global:

$ npx mocha --require esm --require ignore-styles --require jsdom-global/register

And … it barfs again. However, this time we don’t get a stacktrace, we get help output from mocha and the error:

/path/to/arctic-sea-ice-map/test/map-test.js:1
import 'chai/register-should';

SyntaxError: The requested module
'file:///path/to/arctic-sea-ice-map/src/js/index.js' does not provide an export named 'default'

This is a good sign! Remember how I mentioned above that we’d not exported anything from index.js even though we’d “modularised” it? This is what the error message is trying to tell us: we’ve not exported anything yet from the thing we’re trying to import stuff from. Add the line

export { map as default };

to the end of index.js and run

$ npx mocha --require esm --require ignore-styles --require jsdom-global/register

again. It works! Well, the test suite runs (which is good), but the test fails (which is bad), however we’ve managed to get the infrastructure wired up so that we can now start developing the code in a much more professional manner.

I’m getting tired of having to write this long mocha command line each time, so how about we turn this into npm and make targets?

First, add these lines to the Makefile:

test:
	npm run test

and add the test target to the list of .PHONY targets so that it always runs:

.PHONY: build test

Now replace the value of the test key in the scripts section in package.json:

    "test": "mocha --require esm --require ignore-styles --require jsdom-global/register",

Now you can run the test suite with the command make test.

The output from the test run should look something like this:

  Basic map
    1) should have a view centred on Prague


  0 passing (46ms)
  1 failing

  1) Basic map
       should have a view centred on Prague:

      AssertionError: expected [ Array(2) ] to deeply equal [ 14.44, 50.07 ]
      + expected - actual

       [
      -  1607453.4470548702
      -  6458407.444894314
      +  14.44
      +  50.07
       ]

      at Context.<anonymous> (test/map-test.js:7:43)
      at process.topLevelDomainCallback (domain.js:120:23)

This is trying to tell us that the numbers we expected in the array are rather different to those that we got back from the getCenter() function in the test. Why is this? Remember that we used the function fromLonLat() in index.js? This is because we had to convert the longitude and latitude values (in degrees) into the relevant map units (which are basically in metres relative to the point (0, 0); more on this topic later) and so roughly 14 degrees longitude (east) is 1607453 m along the x-axis and roughly 50 degrees latitude (north) is roughly 6458407 m along the y-axis. Anyway, what we have to do is convert the centre position we get back from getCenter() into a longitude/latitude pair and compare that with our expected value.

Let’s change our test to read like this:

    it('should have a view centred on Prague', function () {
        const mapCenterCoords = toLonLat(map.getView().getCenter());
        mapCenterCoords.should.deep.equal([14.44, 50.07]);
    });

where we’ve extracted the coordinates of the centre location into a variable and we’re checking that this variable matches what we expect. We also need to import the toLonLat() function, so add this code to the list of import statements at the top of the test file:

import {toLonLat} from 'ol/proj';

Running make test again gives this output:

  Basic map
    1) should have a view centred on Prague


  0 passing (12ms)
  1 failing

  1) Basic map
       should have a view centred on Prague:

      AssertionError: expected [ Array(2) ] to deeply equal [ 14.44, 50.07 ]
      + expected - actual

       [
      -  14.439999999999998
      -  50.06999999999999
      +  14.44
      +  50.07
       ]

      at Context.<anonymous> (test/map-test.js:8:53)
      at process.topLevelDomainCallback (domain.js:120:23)

So close! (But no cigar.) We’ve just run into a problem that plagues computational scientists worldwide: floating point numbers have a finite precision and hence can’t be represented exactly in a computer. In other words, we’ve got a problem with rounding. The solution is to test if the values are close to one another within a given error tolerance. For this, we can use the closeTo assertion; we also have to refactor the test a little bit, because this assertion can’t handle arrays. The toLonLat() function returns the longitude and latitude values as an array (the longitude in the first element, the latitude in the second element), therefore we can use array destructuring to set these values, i.e.:

        let lon, lat;
        [lon, lat] = toLonLat(map.getView().getCenter());

we then just need to assert that the lon and lat values are “close to” the value that we’re interested in, within the specified tolerance (for which we’ll use 10e-6). The test now looks like this:

    it('should have a view centred on Prague', function () {
        let lon, lat;
        [lon, lat] = toLonLat(map.getView().getCenter());
        lon.should.be.closeTo(14.44, 1e-6);
        lat.should.be.closeTo(50.07, 1e-6);
    });

Running make test again shows us that the tests pass! Yay!

  Basic map
    ✓ should have a view centred on Prague


  1 passing (8ms)

Phew, that felt like a lot of work, but we got there! Let’s commit this state to the repository:

$ git add test/map-test.js Makefile package-lock.json package.json src/js/index.js
# mention in the commit message that we're bootstrapping the test infrastructure
$ git commit

Recap

This is good stuff: we’ve managed to get the testing infrastructure installed and wired up to run our tests, and we’ve written our first test! That’s worth celebrating, so stand up and do a little dance before moving on with the next part: projections: different ways of looking at the world.

Support

If you liked this post and want to see more like this, please buy me a coffee!

buy me a coffee logo