All About HTML5 Game Development, from Publishing to Monetization

Excalibur: Integration tests with Karma

Excalibur: Integration tests with Karma

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 level logic with XState library. 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 a karma runner to write integration tests. Our project uses both Typescript and Webpack, so we'll need to configure Karma properly.

In the last tutorial we've added a MotionService to move our character.

The controllers rely either on the Excalibur keyboard inputs or on a nipplejs virtual joystick for touch-capable interfaces (e.g. smartphones). Now it's time to write some tests on it!

 

Why Karma and not Jest?

As we will see in a minute, setting up Karma needs some amount of work to be configured. Karma is also a lot slower than Jest. The question that arises naturally is, why not rely on Jest as done for the other tests? We've already Jest in our project, we've already gone through setting it up, so why set up another testing framework? Can't we just use what we have?

The answer depends on the nature of the MotionService. This service relies on the Excalibur keyboard listeners and we access them by


player.scene.engine.input.keyboard.on(Hold, (evt) => { /* handler function */ }));
                    

If we were to use mocks, what would it mean? We should:

  • create a player mock to write when(player.scene).thenReturn(...)
  • create a scene mock to write when(scene.engine).thenReturn(...)
  • create an engine mock to write when(engine.input).thenReturn(...)
  • and so on and so forth

With a little patience we can do this setup (and we've tried). But when we've tried to run the test, then we've found out that some variable from the browser environment were still needed. Then we would have had to find a way to fake it. And to hope that for testing other classes this setup covers all.

According to our knowledge of testing frameworks, it seemed to us easier and more reasonable to forget Jest to run tests in a real browser as the one provided in Karma tests. Do you agree with these considerations? Let us know on our Twitter or Instagram accounts! Let's see now what's required to set up these integration tests.

Karma set up

Karma installation

First thing first let's add Karma to our project by running


npm i karma
                    

Karma configuration for a Typescript and Webpack project

Our npm project runs with Typescript and Webpack. Therefore, we need to write some configuration to let Karma be aware of these aspects of our code. First thing create a karma.conf.js file in the root directory so that the project structure looks as


sword-adventure
    /src
    ...
    /karma.conf.js
    /tsconfig.json
    ...
                    

When running karma start in your terminal, Karma will load automatically the karma.conf.js as long as it keeps its name. If you want to have a custom-karma.conf.js file, you'll need to run karma start custom-karma.conf.js.

We are going to work now on the karma.conf.js. There are two major configurations to set up. The first is to tell Karma to preprocess all *.ts file with webpack. We'll do it by writing:



module.exports = function (config) {
    config.set({
        // frameworks to use
        // available frameworks: https://npmjs.org/browse/keyword/karma-adapter
        frameworks: ["jasmine", "webpack"],

        preprocessors: {
            'test/**/*.ts': ['webpack'],
            'test/**/*.js': ['webpack'],
        }
    })
}
                    

All of the Karma tests we'll write will stay under the <root></root>/test folder. With these lines we're making Karma aware of the jasmine and webpack frameworks, and we've set Karma to preprocess *.ts and *.js files under the test folder with webpack. What we miss now is exactly the webpack configuration for the Karma environment:


webpack: {
    watch: true,
    devtool: 'inline-source-map',
    mode: 'development',
    module: {
        rules: [
                {
                    test: /\.ts$/,
                    use: 'ts-loader',
                },
                {
                    test: /\.(png|svg|jpg|jpeg|gif)$/i,
                    type: "asset/resource",
                }
               ]
            },
    resolve: {
        extensions: ['.ts', '.js'],
    },
    devServer: {
        contentBase: path.join(__dirname, './dist'),
        compress: true,
        hot: true,
    },
}
                    

With this configuration we're mainly saying that the *.ts files will be loaded by means of the ts-loader, and that there are various formats which are assets file.

The very last thing we miss is to declare the list of files and patterns to load in the test browser, together with some instructions for the Karma server:


files: [
    'test/test.setup.ts',
    'test/*.ts', 'test/*.js',
    {pattern: "**/*.json", watched: false, included: false, served: true, nocache: false},
    {pattern: "**/*.png", watched: false, included: false, served: true, nocache: false},
    {pattern: "**/*.jpg", watched: false, included: false, served: true, nocache: false},
    {pattern: "**/*.svg", watched: false, included: false, served: true, nocache: false},
],
                    

The test.setup.ts file includes the reflect-metadata import we need for using annotations in Typescript (such as @Service()).

Everything is set up. We're ready to write our test!

A test Excalibur Scene

To write the MotionService test, we first need to recreate a loaded Game with a Scene and an Actor in it. We're going to write a utility module called ExcaliburTestUtil. Remember that Karma runs tests on real browsers. Therefore, we need to recreate an environment very similar to the one we use for our code. Looking at our index.html and to our Game and JoystickFactory classes, we see that we use a canvas element to load the Excalibur Game instance, and that we use a div element with id="joystick" to create the nipplejs virtual joystick. Then, first thing first our utility function has to do the same:


export module ExcaliburTestUtils {

    export async function anExcaliburJSGame(): Promise<{ game: Game; scene: TestScene }> {
        const htmlCanvasElement: HTMLCanvasElement = document.createElement('canvas');
        htmlCanvasElement.id = uiConfig.gameCanvaId;
        document.body.appendChild(htmlCanvasElement);

        const joystick: HTMLElement = document.createElement('joystick');
        document.body.appendChild(joystick);
    }

}
                    

Next step is to create the Game, but the Game class already does it when asking for the Game.getInstance() method. We're going to use the real Excalibur Game object, and not a mock one! Having that in hand, we just need to add a test scene and a test actor in it, to load the game and make it start. We can do this in a few lines of code:


export async function anExcaliburJSGame(): Promise<{ game: Game; scene: TestScene }> {
    const htmlCanvasElement: HTMLCanvasElement = document.createElement('canvas');
    htmlCanvasElement.id = uiConfig.gameCanvaId;
    document.body.appendChild(htmlCanvasElement);

    const joystick: HTMLElement = document.createElement('joystick');
    document.body.appendChild(joystick);

    const game: Game = Game.getInstance();
    const scene: TestScene = new TestScene();
    game.add('test-scene', scene);
    await game.start();
    game.goTo('test-scene' as SceneKeys);
    return {game, scene};
}
                    

And that's it! We have a real Game running in a real browser provided by Karma. Now we're really ready to write the MotionService test.

MotionService test

Let's refresh our memory. The MotionService basically provides two methods: one for setting a reference to the Excalibur Actor we'll move, and another to set the kind of motion controller (keyboard inputs or a virtual joystick). We'll test that the latter listeners are properly registered and unregistered.

Let's start by creating a MotionServiceTest.ts file under the test folder. Remember that we've configured Karma to load files in this folder. If you place the file in another place, you need to update also the karma.conf.js file. We're going to write to tests:


it('should register keyboard listeners and unregister joystick listener', () => { ... })
it('should register joystick listener and unregister keyboard listener', () => { ... })
                    

Let's take it a step at a time and begin with the first one. At first, we'll need to create a loaded game and add an actor to it:


it('should register keyboard listeners and unregister joystick listener', () => {
    const {scene: testScene, game} = await ExcaliburTestUtils.anExcaliburGame();
    const actor: Actor = new Actor();
    testScene.add(actor);

});
                    

Then we need to instantiate the MotionService and to call the setMotionType('keyboard') method on it:


motionService = new MotionService();
motionService.setPlayer(actor);

motionService.setMotionType('keyboard');
                    

Lastly we miss the assertions. Looking at the setMotionType method, here's what we can expect:


const {Hold, Release} = Events.EventTypes;
verify(keyboardSpy.on(Hold, anything())).once();
verify(keyboardSpy.on(Release, anything())).once();
verify(joystickFactorySpy.destroy()).once();
                    

To run these assertions, we need to spy both the JoystickFactory and the keyboard objects. This is easily done by adding:


const keyboardSpy: Keyboard = spy(testScene.engine.input.keyboard);
const joystickFactorySpy = spy(Container.get(JoystickFactory));
                    

Our test looks very simple in the end:


it('should register keyboard listeners and unregister joystick listener',  () => {
    const actor: Actor = new Actor();
    testScene.add(actor);
    motionService = new MotionService();
    motionService.setPlayer(actor);
    const keyboardSpy: Keyboard = spy(testScene.engine.input.keyboard);
    const joystickFactorySpy = spy(Container.get(JoystickFactory));

    motionService.setMotionType('keyboard');

    const {Hold, Release} = Events.EventTypes;
    verify(keyboardSpy.on(Hold, anything())).once();
    verify(keyboardSpy.on(Release, anything())).once();
    verify(joystickFactorySpy.destroy()).once();
});
                    

To test the setMotionType('joystick') we need a similar set up and analogous assertions, as in:


...

motionService.setMotionType('joystick');

verify(joystickFactorySpy.destroy()).never();
verify(joystickManagerMock.on('move', anything())).once();
verify(joystickManagerMock.on('end', anything())).once();
const {Hold, Release} = Events.EventTypes;
[Hold, Release].forEach(event => verify(keyboardSpy.off(event)).once());
                    

With a little refactor our MotionServiceTest class will look as:


describe('MotionService', () => {

    let motionService: MotionService;
    let joystickFactorySpy: JoystickFactory;
    let testScene: TestScene;
    let testGame: Game;

    beforeEach(async () => {
        const {scene, game} = await ExcaliburTestUtils.anExcaliburGame();
        testScene = scene;
        testGame = game;
        joystickFactorySpy = spy(Container.get(JoystickFactory));
    });

    afterEach(() => {
        reset(joystickFactorySpy);
    });

    it('should register keyboard listeners and unregister joystick listener',  () => {
        const actor: Actor = new Actor();
        testScene.add(actor);
        motionService = new MotionService();
        motionService.setPlayer(actor);
        const keyboardSpy: Keyboard = spy(testScene.engine.input.keyboard);

        motionService.setMotionType('keyboard');

        const {Hold, Release} = Events.EventTypes;
        verify(keyboardSpy.on(Hold, anything())).once();
        verify(keyboardSpy.on(Release, anything())).once();
        verify(joystickFactorySpy.destroy()).once();
    });

    it('should register joystick listener and unregister keyboard listener', () => {
        const actor: Actor = new Actor();
        testScene.add(actor);
        motionService = new MotionService();
        motionService.setPlayer(actor);
        const keyboardSpy: Keyboard = spy(testScene.engine.input.keyboard);
        const joystickManagerMock: JoystickManager = mock(JoystickManager);
        when(joystickFactorySpy.getJoystick()).thenReturn(instance(joystickManagerMock))

        motionService.setMotionType('joystick');

        verify(joystickFactorySpy.destroy()).never();
        verify(joystickManagerMock.on('move', anything())).once();
        verify(joystickManagerMock.on('end', anything())).once();
        const {Hold, Release} = Events.EventTypes;
        [Hold, Release].forEach(event => verify(keyboardSpy.off(event)).once());
    });

});
                    

As you can see, the test is easy to read and follow. There is no infinite list of mocks as it would have ended up using jest. We had to set up Karma, but in the end, we can be quite satisfied!

You can finally run the test by running it in the terminal:


karma start
                    

while we remember that for Jest tests the command is just


jest
                    

Lastly we can add a couple of scripts to our package.json file:


"scripts": {
  "serve": "webpack serve --config webpack.development.js",
  "jest-test": "jest",
  "karma-test": "karma start",
  "test": "npm run jest-test && npm run karma-test",
  "build:dev": "webpack --config webpack.development.js",
  "build:prod": "webpack --config webpack.production.js"
},
                    

Conclusions

In this tutorial, we've set up Karma to test our code in a real browser environment. Now we can run integration tests with real Excalibur Game, Scene and Actor objects.

In the next tutorial, we'll set up an infinite horizontal scene where our main character can move. Read about it here!

Did you like this article and wish there were more? Donate on Paypal so that I can write more!

Related Articles