Testing Phaser.js Games with Jest: Scenes, Physics, and Input

Testing Phaser.js Games with Jest: Scenes, Physics, and Input

Phaser.js powers thousands of browser games, but testing it is notoriously tricky. The framework depends on browser APIs — WebGL, Canvas, Web Audio, requestAnimationFrame — that don't exist in Node.js. Most teams give up and test manually. This guide shows you a practical approach using Jest with jsdom, strategic mocking, and architectural patterns that make Phaser games actually testable.

The Core Problem

Phaser initializes a WebGL or Canvas renderer on construction. In a Jest/Node environment, this throws immediately. The solution is a three-layer approach:

  1. Mock the browser renderer entirely for logic tests
  2. Test game logic in classes that don't depend on Phaser internals
  3. Test Phaser integration points (scene lifecycle, physics callbacks) with targeted stubs

Project Setup

npm install --save-dev jest jest-environment-jsdom @jest/globals

jest.config.js:

export default {
  testEnvironment: 'jsdom',
  transform: {
    '^.+\\.js$': ['babel-jest', { presets: ['@babel/preset-env'] }]
  },
  moduleNameMapper: {
    // Mock Phaser's WebGL renderer
    'phaser': '<rootDir>/test/__mocks__/phaser.js'
  },
  setupFiles: ['<rootDir>/test/setup.js']
};

Mocking Phaser

Create a minimal Phaser mock that satisfies import Phaser from 'phaser' without touching any browser API:

// test/__mocks__/phaser.js
const EventEmitter = class {
  constructor() { this._events = {}; }
  on(event, fn) { (this._events[event] = this._events[event] || []).push(fn); return this; }
  emit(event, ...args) { (this._events[event] || []).forEach(fn => fn(...args)); return this; }
  off(event, fn) {
    if (this._events[event])
      this._events[event] = this._events[event].filter(f => f !== fn);
    return this;
  }
  once(event, fn) {
    const wrapper = (...args) => { fn(...args); this.off(event, wrapper); };
    return this.on(event, wrapper);
  }
};

const Phaser = {
  Game: jest.fn().mockImplementation(() => ({
    destroy: jest.fn(),
    scene: { add: jest.fn(), start: jest.fn() }
  })),

  Scene: class extends EventEmitter {
    constructor(config) {
      super();
      this.sys = {
        settings: { key: typeof config === 'string' ? config : config.key },
        events: new EventEmitter(),
        scene: { start: jest.fn(), stop: jest.fn(), pause: jest.fn() }
      };
      this.add = {
        image: jest.fn().mockReturnValue({ setOrigin: jest.fn().mockReturnThis(), destroy: jest.fn() }),
        sprite: jest.fn().mockReturnValue({ play: jest.fn(), destroy: jest.fn(), setVelocity: jest.fn() }),
        text: jest.fn().mockReturnValue({ setText: jest.fn(), destroy: jest.fn(), setOrigin: jest.fn().mockReturnThis() }),
        group: jest.fn().mockReturnValue({ add: jest.fn(), getChildren: jest.fn(() => []), clear: jest.fn() }),
      };
      this.physics = {
        add: {
          sprite: jest.fn().mockReturnValue({
            setVelocity: jest.fn().mockReturnThis(),
            setBounce: jest.fn().mockReturnThis(),
            setCollideWorldBounds: jest.fn().mockReturnThis(),
            body: { velocity: { x: 0, y: 0 }, blocked: { down: false } }
          }),
          collider: jest.fn(),
          overlap: jest.fn(),
        },
        world: { setBounds: jest.fn() }
      };
      this.input = {
        keyboard: {
          createCursorKeys: jest.fn(() => ({
            left: { isDown: false },
            right: { isDown: false },
            up: { isDown: false, _justDown: false }
          })),
          addKey: jest.fn().mockReturnValue({ isDown: false })
        },
        on: jest.fn()
      };
      this.cameras = {
        main: { setBounds: jest.fn(), startFollow: jest.fn(), setZoom: jest.fn() }
      };
      this.time = {
        addEvent: jest.fn().mockReturnValue({ remove: jest.fn() }),
        delayedCall: jest.fn()
      };
      this.tweens = { add: jest.fn() };
      this.sound = { add: jest.fn().mockReturnValue({ play: jest.fn(), stop: jest.fn() }) };
      this.events = new EventEmitter();
      this.registry = { get: jest.fn(), set: jest.fn() };
      this.scene = this.sys.scene;
    }
  },

  Physics: {
    Arcade: {
      Sprite: class {
        constructor() {
          this.x = 0; this.y = 0;
          this.body = { velocity: { x: 0, y: 0 }, blocked: { down: false } };
        }
        setVelocity(x, y) { this.body.velocity.x = x; this.body.velocity.y = y; return this; }
        setBounce() { return this; }
        setCollideWorldBounds() { return this; }
        destroy() {}
      }
    }
  },

  AUTO: 'AUTO',
  Scale: { FIT: 'FIT', CENTER_BOTH: 'CENTER_BOTH' }
};

export default Phaser;

Testing Game Scenes

Extract game logic from Phaser's update() into plain classes, then test those classes directly. Keep Phaser-coupled code thin.

// src/systems/PlayerController.js
export class PlayerController {
  constructor(sprite, cursors) {
    this.sprite = sprite;
    this.cursors = cursors;
    this.jumpForce = -400;
    this.moveSpeed = 200;
    this._jumpsRemaining = 2; // double jump
  }

  update() {
    const onGround = this.sprite.body.blocked.down;
    if (onGround) this._jumpsRemaining = 2;

    if (this.cursors.left.isDown) {
      this.sprite.setVelocity(-this.moveSpeed, this.sprite.body.velocity.y);
    } else if (this.cursors.right.isDown) {
      this.sprite.setVelocity(this.moveSpeed, this.sprite.body.velocity.y);
    } else {
      this.sprite.setVelocity(0, this.sprite.body.velocity.y);
    }

    const jumpPressed = this.cursors.up.isDown && !this.cursors.up._wasDown;
    this.cursors.up._wasDown = this.cursors.up.isDown;

    if (jumpPressed && this._jumpsRemaining > 0) {
      this.sprite.setVelocity(this.sprite.body.velocity.x, this.jumpForce);
      this._jumpsRemaining--;
    }
  }
}
// test/systems/PlayerController.test.js
import { PlayerController } from '../../src/systems/PlayerController.js';

function makeSprite() {
  return {
    body: { velocity: { x: 0, y: 0 }, blocked: { down: false } },
    setVelocity: jest.fn(function(x, y) {
      this.body.velocity.x = x;
      this.body.velocity.y = y;
      return this;
    })
  };
}

function makeCursors() {
  return {
    left: { isDown: false },
    right: { isDown: false },
    up: { isDown: false, _wasDown: false }
  };
}

describe('PlayerController', () => {
  let sprite, cursors, controller;

  beforeEach(() => {
    sprite = makeSprite();
    cursors = makeCursors();
    controller = new PlayerController(sprite, cursors);
  });

  it('moves right when right cursor is down', () => {
    cursors.right.isDown = true;
    controller.update();
    expect(sprite.body.velocity.x).toBe(200);
  });

  it('stops when no cursor is pressed', () => {
    sprite.body.velocity.x = 200;
    controller.update();
    expect(sprite.body.velocity.x).toBe(0);
  });

  it('jumps on up press', () => {
    sprite.body.blocked.down = true;
    cursors.up.isDown = true;
    controller.update();
    expect(sprite.body.velocity.y).toBe(-400);
  });

  it('allows double jump in air', () => {
    sprite.body.blocked.down = true;
    cursors.up.isDown = true;
    controller.update(); // first jump
    cursors.up.isDown = false;
    controller.update(); // reset
    cursors.up.isDown = true;
    sprite.body.blocked.down = false;
    controller.update(); // second jump in air
    expect(sprite.body.velocity.y).toBe(-400);
  });

  it('cannot triple jump', () => {
    // Use all jumps
    sprite.body.blocked.down = true;
    cursors.up.isDown = true;
    controller.update();
    cursors.up.isDown = false; controller.update();
    cursors.up.isDown = true; sprite.body.blocked.down = false;
    controller.update(); // second jump
    cursors.up.isDown = false; controller.update();

    const velocityBeforeThirdAttempt = sprite.body.velocity.y;
    cursors.up.isDown = true;
    controller.update(); // should NOT jump
    expect(sprite.body.velocity.y).toBe(velocityBeforeThirdAttempt);
  });
});

Testing Physics Overlap Callbacks

Physics overlap callbacks are the core of collision-based game logic. Test them by extracting the callback function and calling it directly:

// src/scenes/GameScene.js
export class GameScene extends Phaser.Scene {
  constructor() { super({ key: 'GameScene' }); }

  create() {
    this.player = this.physics.add.sprite(400, 300, 'player');
    this.coins = this.add.group();

    this.physics.add.overlap(
      this.player,
      this.coins,
      this.onCoinCollected,
      null,
      this
    );

    this.score = 0;
  }

  onCoinCollected(player, coin) {
    coin.destroy();
    this.score += coin.value || 10;
    this.events.emit('scoreChanged', this.score);
  }
}
// test/scenes/GameScene.test.js
import Phaser from 'phaser';
import { GameScene } from '../../src/scenes/GameScene.js';

describe('GameScene', () => {
  let scene;

  beforeEach(() => {
    scene = new GameScene();
    scene.create();
  });

  describe('coin collection', () => {
    it('increases score by coin value', () => {
      const mockCoin = { destroy: jest.fn(), value: 50 };
      scene.onCoinCollected(scene.player, mockCoin);
      expect(scene.score).toBe(50);
    });

    it('uses default value of 10 when coin has no value', () => {
      const mockCoin = { destroy: jest.fn() };
      scene.onCoinCollected(scene.player, mockCoin);
      expect(scene.score).toBe(10);
    });

    it('destroys the coin on collection', () => {
      const mockCoin = { destroy: jest.fn(), value: 10 };
      scene.onCoinCollected(scene.player, mockCoin);
      expect(mockCoin.destroy).toHaveBeenCalledTimes(1);
    });

    it('emits scoreChanged event', () => {
      const listener = jest.fn();
      scene.events.on('scoreChanged', listener);
      const mockCoin = { destroy: jest.fn(), value: 25 };
      scene.onCoinCollected(scene.player, mockCoin);
      expect(listener).toHaveBeenCalledWith(25);
    });
  });
});

Testing Scene Transitions

// src/scenes/MenuScene.js
export class MenuScene extends Phaser.Scene {
  constructor() { super({ key: 'MenuScene' }); }

  startGame() {
    if (this.sys.scene) {
      this.sys.scene.start('GameScene', { difficulty: this._selectedDifficulty });
    }
  }

  setDifficulty(level) {
    if (!['easy', 'normal', 'hard'].includes(level)) {
      throw new Error(`Invalid difficulty: ${level}`);
    }
    this._selectedDifficulty = level;
  }
}

// test/scenes/MenuScene.test.js
import { MenuScene } from '../../src/scenes/MenuScene.js';

describe('MenuScene', () => {
  let scene;

  beforeEach(() => {
    scene = new MenuScene();
  });

  it('starts game scene with selected difficulty', () => {
    scene.setDifficulty('hard');
    scene.startGame();
    expect(scene.sys.scene.start).toHaveBeenCalledWith('GameScene', { difficulty: 'hard' });
  });

  it('throws on invalid difficulty', () => {
    expect(() => scene.setDifficulty('impossible')).toThrow('Invalid difficulty: impossible');
  });
});

Testing the Update Loop

For testing time-based behavior (cooldowns, spawns, animations), inject a controllable clock:

// src/systems/EnemySpawner.js
export class EnemySpawner {
  constructor(spawnFn, options = {}) {
    this._spawnFn = spawnFn;
    this._interval = options.interval || 3000;
    this._maxEnemies = options.maxEnemies || 10;
    this._enemies = [];
    this._elapsed = 0;
  }

  update(delta) {
    this._elapsed += delta;
    if (this._elapsed >= this._interval && this._enemies.length < this._maxEnemies) {
      const enemy = this._spawnFn();
      this._enemies.push(enemy);
      this._elapsed = 0;
    }
  }

  removeEnemy(enemy) {
    this._enemies = this._enemies.filter(e => e !== enemy);
  }

  get count() { return this._enemies.length; }
}

// test/systems/EnemySpawner.test.js
import { EnemySpawner } from '../../src/systems/EnemySpawner.js';

describe('EnemySpawner', () => {
  let spawnFn, spawner;

  beforeEach(() => {
    spawnFn = jest.fn(() => ({ id: Math.random() }));
    spawner = new EnemySpawner(spawnFn, { interval: 3000, maxEnemies: 3 });
  });

  it('spawns enemy after interval', () => {
    spawner.update(3000);
    expect(spawnFn).toHaveBeenCalledTimes(1);
    expect(spawner.count).toBe(1);
  });

  it('does not spawn before interval', () => {
    spawner.update(2999);
    expect(spawnFn).not.toHaveBeenCalled();
  });

  it('respects max enemy limit', () => {
    spawner.update(3000); // spawn 1
    spawner.update(3000); // spawn 2
    spawner.update(3000); // spawn 3
    spawner.update(3000); // at max, no spawn
    expect(spawner.count).toBe(3);
    expect(spawnFn).toHaveBeenCalledTimes(3);
  });
});

CI Configuration

name: Phaser Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with: { node-version: '20' }
      - run: npm ci
      - run: npm test -- --coverage --ci
      - uses: codecov/codecov-action@v3

Conclusion

Testing Phaser.js effectively means accepting that you cannot unit-test the renderer — and that's fine. Extract game logic into framework-agnostic classes (systems, controllers, state machines), test those classes with pure Jest, and use targeted stubs for the Phaser integration layer. The physics callback pattern (extract function, call directly) and the injected clock pattern eliminate the two biggest sources of test pain in browser games. With this architecture, your game logic has fast, reliable test coverage from day one.

Read more