HTML5 Game unitary testing with Jest in ExcaliburJS engine
This is an open-source project on game development with a focus on the HTML5 Excalibur game engine. Check out this feature implementation on this commit in our Github repository!
You can read here the list of features we are going to develop for our Excalibur game, such as an Ionic React UI interface. If you're interested in game development, follow us on Twitter and Instagram to be caught up on our latest news!
Table of Contents
In this tutorial
In this tutorial we set up jest for writing unitary tests.
In the last tutorial we added an animated character to our game. We've done a lot of work and we've written a lot of new code. Now it's time to run some tests on it!
Jest setup
Dependencies
To set up Jest to work with Webpack, we'll follow what's written in Jest documentation.
Let's begin by installing the Jest and babel dependencies for working with Typescript. Open the terminal and run
npm install --save-dev jest
npm install --save-dev babel-jest @babel/core @babel/preset-env @babel/preset-typescript
Since we are working in a browser environment, we also need to install the jsdom
and the canvas
jest packages:
npm install --save-dev jest-environment-jsdom
npm i --save-dev jest-canvas-mock
Configuration files
Now that we've installed all the required dependencies, we can proceed by setting up the configuration files. After having had a look at Jest documentation for setting up Jest and Webpack, we create a jest.config.ts
file under the project root folder sword-adventure
.
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
setupFiles: [
"jest-canvas-mock"
],
setupFilesAfterEnv: ['<rootDir>/test.setup.ts'],
moduleNameMapper: {
"\\.(jpg|ico|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/test/fileMock.js",
"\\.(css|less)$": "<rootDir>/test/fileMock.js",
},
transform: {"^.+\\.(js|jsx|mjs)$": "<rootDir>/node_modules/babel-jest"},
transformIgnorePatterns: ["/node_modules"],
resolver: undefined
};
Let's have a look at this configuration file.
- The test environment is
jsdom
, since Excalibur runs into a browser. If we were developing for example a server with node, the test environment would be another one. - We need
jest-canvas-mock
to mock thecanvas
on which Excalibur relies on. - The
test.setup.ts
is a file placed at the same level ofjest.config.ts
, containing a single import:
We need this import for theimport 'reflect-metadata';
typedi
library to work. In production code, we've included this import inindex.ts
file. - The module name mapper contains a sort of "redirection" so that real assets file won't be imported when testing. In their place, there is a mock file which exports a simple string:
fileMock.js
is as simple asmodule.exports = 'test-file-stub';
- The last two options,
transform
andtransformIgnorePatterns
, are a way to transform code that Jest is not able to run by itself. You can read more about this topic here.
The last configuration file we're missing is babel.config.js
, which by following the documentation is as simple as
module.exports = {
presets: [
['@babel/preset-env', {targets: {node: 'current'}}],
'@babel/preset-typescript',
],
};
Set up package.json scripts
Now that all the dependencies are installed and that all the configuration files are ready, we can add a few commands in our package.json
scripts:
...
"scripts": {
"serve": "webpack serve --config webpack.development.js",
"jest-test": "jest",
"build:dev": "webpack --config webpack.development.js",
"build:prod": "npm run jest-test && webpack --config webpack.production.js",
}
...
As you can see, now before building the source code in production mode, we run tests.
Unitary tests
We're ready to start writing the tests. The classes that needs them are ExcaliburActor
, PlayScene
and GraphicService
.
ExcaliburActor
We should test that after calling the onInitialize()
method, the GraphicService
has been called to register actor graphics:
describe('ExcaliburActor', () => {
let graphicService: GraphicService;
beforeEach(() => {
graphicService = spy(Container.get(GraphicService));
});
afterEach(() => {
reset(graphicService);
});
it('should initialize actor', () => {
const args: ActorArgs = {} as ActorArgs;
const actor: ExcaliburActorTest = new ExcaliburActorTest(args, {tag: 'test_tag', collisionGroupKey: 'collisiong_group'});
const engine: Engine = mock(Engine);
actor.onInitialize(engine);
verify(graphicService.registerActorGraphics('player', actor)).once();
verify(graphicService.registerActorAnimations('player', actor)).once();
});
});
class ExcaliburActorTest extends ExcaliburActor {
public COLLISION_GROUP_NAME: string = 'test';
public type: ActorType = 'player';
}
In this test we're using ts-mockito
mocking library. We could use only Jest, but in our opinion ts-mockito
is easier, so we'll use it.
Back to the ExcaliburActor
test, it's pretty simple and straightforward. We just need to spy
on the GraphicService
to check with a couple of verify
that it has been called after actor initialization.
PlayScene
The PlayScene
is quite simple, as the ExcaliburActor
one. Also in this case we need to check that after initializing the scene, a player actor is added to it. The easiest way to test it is by performing a little refactor on the PlayScene
. If the actor was created using a ActorFactory
, then we would check that the ActorFactor was called once. The ActorFactory can be done as:
@Service()
export class ActorFactory {
public createPlayer(actorArgs: ActorArgs): Player {
const {TAGS, COLLISION_GROUPS} = ActorTags;
const actorConfig: ActorConfig = {tag: TAGS.player, collisionGroupKey: COLLISION_GROUPS.player};
return new Player(actorArgs, actorConfig);
}
}
Accordingly, the PlayScene
changes as:
export class PlayScene extends Scene {
private actorFactory: ActorFactory = Container.get(ActorFactory);
public onInitialize(): void {
...
const args: ActorArgs = {
pos: vec(width / 2, height / 2),
collisionType: CollisionType.Active,
collider: Shape.Box(100, 100),
};
const player: Player = this.actorFactory.createPlayer(args);
...
}
}
With the code being refactored as above, the PlayScene
class test is quite easy and resembles the one of the ExcaliburActor
, where we spy
on a service and verify
that the service has been called:
describe('PlayScene', () => {
let actorFactory: ActorFactory;
beforeEach(() => {
actorFactory = spy(Container.get(ActorFactory));
});
afterEach(() => {
reset(actorFactory);
});
it('should initialize the scene with a player actor', () => {
const scene: PlayScene = new PlayScene();
scene.onInitialize();
expect(scene.actors.length).toBe(1);
verify(actorFactory.createPlayer(anything())).once();
});
});
By creating the ActorFactory
class we've gained another class to test, but luckily enough its test is as easy as the others:
describe('ActorFactory', () => {
let actorFactory: ActorFactory;
beforeEach(() => {
actorFactory = new ActorFactory();
});
it('should create player actor', () => {
const actorArgs: ActorArgs = {
pos: vec(50, 50),
collisionType: CollisionType.Active,
collider: Shape.Box(100, 100),
};
const player: Player = actorFactory.createPlayer(actorArgs);
expect(player.tags.includes(ActorTags.TAGS.player)).toBe(true);
});
});
GraphicService
Last but not least we have to test the GraphicService
, which is perhaps the most important class to test until now since if it doesn't work, nothing will be displayed properly in the game.
describe('GraphicService', () => {
let graphicService: GraphicService;
let allGraphics: typeof AllGraphics;
beforeEach(() => {
graphicService = new GraphicService();
allGraphics = spy(AllGraphics);
});
it('should register actor graphics', () => {
const actor: Actor = new Actor();
const expected: Graphic = mock(Graphic);
const actorGraphic: ActorGraphic = {player: [{graphic: expected, name: 'hurt'}]};
when(allGraphics.textures).thenReturn(actorGraphic)
graphicService.registerActorGraphics('player', actor);
const graphicsComponent: GraphicsComponent = actor.graphics;
const actual: Graphic = graphicsComponent.getGraphic('hurt');
expect(actual).toEqual(expected);
});
it('should register actor animations', () => {
const actor: Actor = new Actor();
const expected: Animation = mock(Animation);
const actorAnimations: ActorAnimations = {player: [{animation: expected, name: 'run'}]};
when(allGraphics.animations).thenReturn(actorAnimations)
graphicService.registerActorAnimations('player', actor);
const graphicsComponent: GraphicsComponent = actor.graphics;
expect(Array.from(Object.keys(graphicsComponent.graphics)).length).toBe(1);
const actual: Graphic = graphicsComponent.getGraphic('run');
expect(actual).toEqual(expected);
});
});
In this test we are mocking the AllGraphics
module. This is not strictly necessary since at this stage we could easily use the real one.
The two tests consist in checking that the actor has the graphics as configured in the AllGraphics
mocked module.
Conclusions
In this tutorial we've set up a jest
configuration for a Typescript and Webpack npm project, and we wrote some unitary tests for our classes. At this stage, we have a very simple Excalibur game that contains a simple scene and an animated character. The next step is to control the actor using a keyboard or a virtual joystick: check out our next tutorial!
Did you like this article and wish there were more? Donate on Paypal so that I can write more!