A woman looking at Jest on Canvas picture

How to test HTML5 canvas with jest?

In this short article you will learn what you need to install in order to prepare a test environment for canvas operations with jest. After finishing the article, you will be ready for some canvas testing action!

In the past few months, my kid and I been building HTML5 games. I’d might actually get you permission to sign an NDA to see the outcome of these sessions. Until then, I can share some of my experience from meddling in the world of Context2D…

Since my son and I are both keen on testing, we struggled with testing canvas operations using Jest. Jest is using JSDom, which doesn’t implement EVERYTHING available in the browser. In addition, we had some issues using ES6 imports.

How did we solve all this? In one word. Google. In many words… Point Down Icon

TL;DR

  • Trying to work with Path2D will resolve in an error: ReferenceError: Path2D is not defined. You can solve it this way:
  1. Install jest, canvas and jest-canvas-mock
  2. Add a setup file to the jest configuration:

    "jest": {

    "setupFiles": ["jest-canvas-mock"]
    }
  3. Run your tests.


  • Trying to work with DOMMatrix will resolve in an error: TypeError: MATRIX.translate is not a function. You can solve it this by extending the DOMMatrix mock or use the newest version of the package (this PR solved it).

How to build a test environment for canvas with Jest

Building something with HTML5 canvas is fun. Testing with Jest is a pleasure. Let’s give it a try!

If you want to code along, feel free to fork/clone the repository and checkout the steps tags (step-1 to step-6).

Installing Jest

We will start with an HTML and JS file in our repository: https://github.com/YonatanKra/testing-canvas-with-jest/tree/step-1

The first files in our repository

Our next step would be to install the dependencies needed to start testing. Let’s install Jest:

  1. Run npm init in order to generate a package.json file.
  2. Now we can install jest: npm install jest -D

Our package.json should now look like this:

{
"name": "testing-canvas-with-jest",
"version": "1.0.0",
"description": "Showing how to test canvas with jest",
"main": "index.js",
"scripts": {
"test": "jest"
},
"repository": {
"type": "git",
"url": "git+https://github.com/YonatanKra/testing-canvas-with-jest.git"
},
"keywords": [
"jest",
"canvas",
"javascript"
],
"author": "YonatanKra <[email protected]>",
"license": "MIT",
"bugs": {
"url": "https://github.com/YonatanKra/testing-canvas-with-jest/issues"
},
"homepage": "https://github.com/YonatanKra/testing-canvas-with-jest#readme",
"devDependencies": {
"jest": "^27.0.6"
}
}
view raw package.json hosted with ❤ by GitHub

You can get to this stage by checking out the tag step-2 in the repository.

Now we should be able to start testing.

Writing our first test and code

What we’d like to do is create a function that draws a castle with windows and a gate on our canvas. It will look like this:

Our amazing castle

The html and js code will look like this:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Testing Canvas with Jest</title>
</head>
<body>
<canvas id="castle" width="600" height="600"></canvas>
<script type="module">
import {draw} from "./index.js";
const canvas = document.getElementById('castle');
const ctx = canvas.getContext('2d');
draw(ctx);
</script>
</body>
</html>
view raw index.html hosted with ❤ by GitHub
const castlePath = new Path2D('M 0,440 V 95 H 45.5 V 140 H 91 V 95 H 136.5 V 200 H 182 V 155 H 227.5 V 200 H 273 V 155 H 318.5 V 200 H 364 V 90 H 409.5 V 140 H 455 V 90 H 500.5 V 440 Z');
const windowsPath = new Path2D('M 60,297 V 222 H 75.5 V 297 Z M 204.5,425 V 330 A 45.75 45.75 0 0 1 296,330 V 425 Z M 424,297 V 222 H 439.5 V 297 Z');
export function draw(ctx) {
const shapePath = new Path2D();
const castleShape = new Path2D();
const castleWindowsShape = new Path2D();
castleShape.addPath(castlePath);
castleWindowsShape.addPath(windowsPath);
shapePath.addPath(castleShape);
shapePath.addPath(castleWindowsShape);
ctx.fillStyle = 'gray';
ctx.fill(shapePath);
ctx.fill(castleShape);
ctx.fillStyle = 'white';
ctx.fill(castleWindowsShape);
ctx.lineWidth = 2;
ctx.strokeStyle = 'yellow';
ctx.stroke(shapePath);
ctx.stroke(castleShape);
return shapePath;
}
view raw index.js hosted with ❤ by GitHub

Note the type=module on the script tag which allows us to use ES6 imports natively in the browser.

We will now create a test file and try to run jest. Here’s the file:

const { draw } = require('./index');
describe('draw', () => {
it(`should compile`, function () {
});
});
view raw index.spec.js hosted with ❤ by GitHub

This file just tries to require our draw function and does nothing more. You can view this phase by checking out step-3 (or just look here).

Running jest (or npm run test) will result in the following error: Jest encountered an unexpected token. It will then show us that the export in our code is unexpected (line 4 in our .js file).

How to allow ES6 imports in Jest?

Jest comes integrated with Babel in order to support advanced EcmaScript specs. It looks for a babel configuration (in a .babelrc file). In order to easily use ES6 imports in Jest, we should do the following:

  1. npm i -D @babel/plugin-transform-modules-commonjs
  2. Create a .babelrc file with the following content:
{
  "env": {
    "test": {
      "plugins": ["@babel/plugin-transform-modules-commonjs"]
    }
  }
}

Now when we run the tests (npm run test or just jest) the test will succeed in importing but we will get a different error: ReferenceError: Path2D is not defined

How to solve ReferenceError: Path2D is not defined

This error happens because Jest runs on JSDom and not in a real browser. Luckily, some good people thought about us and wrote two useful libraries: canvas and jest-canvas-mock. In order to solve this last issue, do the following:

  1. npm i -D canvas jest-canvas-mock
  2. Add a setup file to jest config (in my case it will be in package.json but you can add it to an external config file if you have one):
"jest": {

    "setupFiles": ["jest-canvas-mock"]
}

and… HOORAY!

Yea! All the tests are passing! We can start testing canvas with Jest!

You can view these changes by checking out step-4 or view it on github.

How to test canvas operations with Jest?

Let’s write simple tests for our code:

const { draw } = require('./index');
describe('draw', () => {
let canvas, ctx;
beforeEach(function() {
canvas = document.createElement('canvas');
ctx = canvas.getContext('2d');
});
it(`should return the shape's path`, function() {
const shapePath = draw(ctx);
expect(shapePath instanceof Path2D).toBeTruthy();
});
it(`should draw a house on the canvas using the main ctx`, function() {
draw(ctx);
const events = ctx.__getEvents();
expect(events).toMatchSnapshot();
});
});
view raw index.spec.js hosted with ❤ by GitHub

The code above verifies two things:

  1. That draw returns a Path2D
  2. That draw ran certain commands on our 2DContext.

The ctx.__getEvents method used in the code above is given to us via jest-canvas-mock. It spies on the context’s methods calls and logs them for us. We then create a snapshot of it and make sure that as long as we follow the same API call, we still have the same canvas procedure.

Jest has a built-in snapshot feature. It generates stringified snapshots and saves them into a file. These snapshots are hashed according to the tests’ describe and it descriptions. It’s kind of like Visual Regression, only for JavaScript data Objects (JSON, Arrays or strings).

Here’s the snapshot taken:

// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`draw should draw a house on the canvas using the main ctx 1`] = `
Array [
Object {
"props": Object {
"value": "#808080",
},
"transform": Array [
1,
0,
0,
1,
0,
0,
],
"type": "fillStyle",
},
Object {
"props": Object {
"fillRule": "nonzero",
"path": Array [],
},
"transform": Array [
1,
0,
0,
1,
0,
0,
],
"type": "fill",
},
Object {
"props": Object {
"fillRule": "nonzero",
"path": Array [],
},
"transform": Array [
1,
0,
0,
1,
0,
0,
],
"type": "fill",
},
Object {
"props": Object {
"value": "#ffffff",
},
"transform": Array [
1,
0,
0,
1,
0,
0,
],
"type": "fillStyle",
},
Object {
"props": Object {
"fillRule": "nonzero",
"path": Array [],
},
"transform": Array [
1,
0,
0,
1,
0,
0,
],
"type": "fill",
},
Object {
"props": Object {
"value": 2,
},
"transform": Array [
1,
0,
0,
1,
0,
0,
],
"type": "lineWidth",
},
Object {
"props": Object {
"value": "#ffff00",
},
"transform": Array [
1,
0,
0,
1,
0,
0,
],
"type": "strokeStyle",
},
Object {
"props": Object {
"path": Array [],
},
"transform": Array [
1,
0,
0,
1,
0,
0,
],
"type": "stroke",
},
Object {
"props": Object {
"path": Array [],
},
"transform": Array [
1,
0,
0,
1,
0,
0,
],
"type": "stroke",
},
]
`;

We can, of course, do more complex verifications. For instance, we can parse the __getEvents output to verify certain things (which will be more TDD-like).

You can find the working code by checking out step-5 or view it on github.

How to test DOMMatrix operations with Jest

Now that our function is working, we’d like to add some parameters like sizing. We resize Path2D paths using a DOMMatrix which helps us translate and scale our paths.

Let’s add the code that does that and try to run our test:

const castlePath = new Path2D('M 0,440 V 95 H 45.5 V 140 H 91 V 95 H 136.5 V 200 H 182 V 155 H 227.5 V 200 H 273 V 155 H 318.5 V 200 H 364 V 90 H 409.5 V 140 H 455 V 90 H 500.5 V 440 Z');
const windowsPath = new Path2D('M 60,297 V 222 H 75.5 V 297 Z M 204.5,425 V 330 A 45.75 45.75 0 0 1 296,330 V 425 Z M 424,297 V 222 H 439.5 V 297 Z');
export function draw(ctx, castleOptions = { position: { x: 0, y: 0 }, scaleX: 1, scaleY: 1 }) {
const MATRIX = new DOMMatrix();
const matrix = MATRIX.translate(castleOptions.position.x, castleOptions.position.y).scale(castleOptions.scaleX, castleOptions.scaleY);
const shapePath = new Path2D();
const castleShape = new Path2D();
const castleWindowsShape = new Path2D();
castleShape.addPath(castlePath);
castleWindowsShape.addPath(windowsPath);
shapePath.addPath(castleShape);
shapePath.addPath(castleWindowsShape);
ctx.setTransform(matrix);
ctx.fillStyle = 'gray';
ctx.fill(shapePath);
ctx.fill(castleShape);
ctx.fillStyle = 'white';
ctx.fill(castleWindowsShape);
ctx.lineWidth = 2;
ctx.strokeStyle = 'yellow';
ctx.stroke(shapePath);
ctx.stroke(castleShape);
ctx.resetTransform();
return shapePath;
}
view raw index.js hosted with ❤ by GitHub

Note the creation of DOMMatrix in line 6, the translation and scale setup in line 7 and the usage in lines 19 when setting the transform to the drawn shape.

Now running jest or npm run test will result in an error: TypeError: MATRIX.translate is not a function

Note that if we did not use jest-canvas-mock our error would have been: ReferenceError: DOMMatrix is not defined. For the same reason Path2D was undefined.

Solving TypeError: MATRIX.translate is not a function

This can be easily solved by mocking the DOMMatrix class. DOMMatrix is, unfortunately, not implemented by JSDom (and probably not going to be). It is implemented by jest-canvas-mock, but it is missing the translate and scale methods and from this PR it also supports the translate method.

If you are using an old version and don’t want to upgrade, here’s the mocking code:

(function mockDOMMatrix() {
class DOMMatrixMock extends DOMMatrix {
scale = jest.fn().mockImplementation((scaleX, scaleY) => this.setScale(scaleX, scaleY));
translate = jest.fn().mockImplementation((x, y) => this.setTranslate(x,y));
setScale(scaleX, scaleY) {
this.f = scaleY;
this.e = scaleX;
return this;
}
setTranslate(x,y){
this.b = x;
this.c = y;
return this;
}
}
global.DOMMatrix = DOMMatrixMock;
})();

Adding this to the top of our test file, will run our tests without this error.

Note that if you need this mock in more than one file, you can set it up in a separate file and use it in the setupFiles section in the Jest config. This way, you won’t have to copy-paste the snippet all over your test files.

All the tests are now passing. We can now also write tests for our new API and close this section:

    it(`should draw a house on the canvas using the default scaleX and scaleY`, function() {
        const path = draw(ctx);
        const events = ctx.__getEvents();

        expect(events).toMatchSnapshot();
    });

    it(`should draw a house with given position, scaleX and scaleY`, function() {
        const path = draw(ctx, { position: { x: 10, y: 10 }, scaleX: .5, scaleY: 0 });
        const events = ctx.__getEvents();

        expect(events).toMatchSnapshot();
    });

These tests also pass.

HOORAY!

You can view the full code in step-6 or view it on github

Should we use toMatchSnapshot calls?

A discussion in one of the forum was raised due to the usage of snapshots in this article. The TL;DR of this discussion is “With great power comes great responsibility”.

I use snapshots as a shortcut here. I’ve also raised the point that I’d probably not use them. Instead, I’d usually take the results of the __getEvents method (which are valid JSON) and use them in some way.

For instance, in our case we have a massive log. It just represents the list of operations done on our 2Dcontext. If I know the 2nd operation should be a fill operation with a certain color – I can verify this by accessing the second member in the log and verify its type is fill with the certain color.

Using the snapshot, in this case, made me test the whole flow in one go. Of course, I’d have to verify and approve the first log I take, but from then on, every change to that log should raise an alarm – have we changed anything?

This, of course, reminds veteran testers with the good old unit vs. e2e tests question.

Testing specific things (like a certain part/unit in a process) can give you a very specific cause for an error. On the other hand – the more general tests (like e2e, UI and snapshots) are less specific and give you an impression that “something might be wrong – please investigate”.

It’s always a matter of tradeoffs. Using snapshots is faster, but gives you less refined error handling. It’s probably more prone to false alarms…

Thanks Ido Wald for bringing this important issue up on Facebook.

Summary

Wow! What a ride! Now I hope we know better how to test canvas using Jest.

Testing canvas with Jest might seem ominous at first. With some trial, error and google, everything is solvable.

From solving a simple import problem through mocking the 2d context and Path2D to extending the DOMMatrix mock itself – you are now ready to lunch your next canvas based app with the security of testing with Jest.

In our example, you can test your new canvas testing skills to add more API’s and test them. One example could be adding color and stroke to the configuration…

If you are looking for serverside mocking techniques, I’ve just read a really cool tip on how to mock a database. I hope you will enjoy it like I did.

Since this is my first actual research of testing Canvas operations, I’d love to read your feedback and experience in the field. Feel free to use the comments below to share your opinion or just holler me over FB/Twitter/Linkedin.

Thanks a lot for Shai Reznik from hirez.io and Miki Ezra Stanger for a very kind and helpful review!

5 1 vote
Article Rating
Subscribe
Notify of
guest

2 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Douglas Counts
Douglas Counts
10 months ago

This article is great but is dated on one very specific important point, so you may still get the “ReferenceError: Path2D is not defined” error.

But the fix is easy.

For the last several years, Jest by default is using the node environment, not the JSdom environment anymore.

So what you do is add "testEnvironment": "jsdom", to the top of the jest section of the package.json file. Problem solved.

“jest”: {
“testEnvironment”: “jsdom”,
“setupFiles”: [“jest-canvas-mock”]
}

You may also need to install as a developer dependency, the library jest-environment-jsdom, but I didn’t need to do that.

These suggestions are from the issues section of the GitHub for this article as posted by Mariusz Leśniak.

Douglas Counts
Douglas Counts
10 months ago

As of Jest 28, “jest-environment-jsdom” is no longer shipped by default, make sure to install it separately. If you just download the files from the GitHub which uses Jest version 27, all you need to do is add the one line to the package.json file like so:

“jest”: {
“testEnvironment”: “jsdom”,
“setupFiles”: [
“jest-canvas-mock”
]
},