Developing an OpenLayers app from scratch in ES6 using Mocha, Webpack and Karma: Mocha tests
… 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!
