| name | qa-phaser-unit-testing |
| description | Phaser unit testing patterns with MockScene. Provides patterns for testing Phaser objects, Matter.js physics, and game systems without requiring a running game instance. |
| category | testing |
Phaser Unit Testing Patterns
"Test Phaser game objects without a running game instance."
When to Use This Skill
Use when creating unit tests for:
- Phaser game objects (Birds, Pigs, Blocks, etc.)
- Matter.js physics bodies
- Game systems that depend on Phaser Scene
- Event-driven game logic
CRITICAL LESSON FROM BUG-011: Never use real Phaser Scene instances in unit tests. They require a running Phaser Game instance and will fail with errors like "scene.init is not a function". Always use MockScene pattern.
The MockScene Pattern
Why MockScene is Required
Real Phaser Scenes need:
- A running Phaser.Game instance
- Canvas element in DOM
- Full Phaser engine initialization
- Matter.js physics engine context
This makes unit tests:
- Slow (full engine startup)
- Brittle (DOM dependencies)
- Complex (setup/teardown)
- Unreliable (timing issues)
MockScene Pattern Solution:
Create comprehensive mocks that simulate Phaser Scene APIs without requiring the full engine.
Complete MockScene Implementation
import { EventEmitter } from 'events';
interface MockMatterBody {
label: string;
isStatic: boolean;
mass: number;
position: { x: number; y: number };
velocity: { x: number; y: number };
angularVelocity: number;
}
interface MockMatterImage {
body: MockMatterBody;
setCircle: (radius: number) => void;
setRectangle: (width: number, height: number) => void;
setStatic: (isStatic: boolean) => void;
setPosition: (x: number, y: number) => void;
setVelocity: (x: number, y: number) => void;
setAngularVelocity: (velocity: number) => void;
}
interface MockGraphics {
fillStyle: (color: number, alpha?: number) => MockGraphics;
fillRect: (x: number, y: number, width: number, height: number) => MockGraphics;
clear: () => void;
}
interface MockCamera {
shake: (duration: number, intensity?: number) => void;
}
export class MockScene extends EventEmitter {
public matter: {
add: {
image: (x: number, y: number, key: string, shape?: string) => MockMatterImage;
}
};
public events: {
emit: (event: string, ...args: any[]) => void;
on: (event: string, handler: Function) => void;
off: (event: string, handler?: Function) => void;
};
public tweens: {
add: (config: any) => { pause: () => void; destroy: () => void };
};
public cameras: {
main: MockCamera;
};
public add: {
graphics: () => MockGraphics;
};
public time: {
delayedCall: (delay: number, callback: Function, args?: any[]) => { destroy: () => void };
addEvent: (config: any) => { destroy: () => void };
now: number;
};
private _eventHandlers: Map<string, Set<Function>> = new Map();
constructor() {
super();
this._setupMocks();
}
private _setupMocks() {
this.matter = {
add: {
image: (x: number, y: number, key: string) => ({
body: {
label: key,
isStatic: false,
mass: 1,
position: { x, y },
velocity: { x: 0, y: 0 },
angularVelocity: 0,
},
setCircle: () => {},
setRectangle: () => {},
setStatic: () => {},
setPosition: () => {},
setVelocity: () => {},
setAngularVelocity: () => {},
}),
},
};
this.events = {
emit: (event: string, ...args: any[]) => {
this.emit(event, ...args);
},
on: (event: string, handler: Function) => {
if (!this._eventHandlers.has(event)) {
this._eventHandlers.set(event, new Set());
}
this._eventHandlers.get(event)!.add(handler);
this.on(event, handler);
},
off: (event: string, handler?: Function) => {
if (handler) {
this._eventHandlers.get(event)?.delete(handler);
this.off(event, handler);
} else {
this._eventHandlers.delete(event);
this.removeAllListeners(event);
}
},
};
this.tweens = {
add: (config: any) => ({
pause: () => {},
destroy: () => {},
}),
};
this.cameras = {
main: {
shake: () => {},
},
};
this.add = {
graphics: () => ({
fillStyle: () => this,
fillRect: () => this,
clear: () => {},
}),
};
this.time = {
delayedCall: () => ({ destroy: () => {} }),
addEvent: () => ({ destroy: () => {} }),
now: 0,
};
}
destroy() {
this._eventHandlers.clear();
this.removeAllListeners();
}
}
Using MockScene in Tests
import { describe, test, expect, beforeEach, vi } from 'vitest';
import { MockScene } from '../helpers/MockScene';
import { SmallPig } from '@/objects/pigs/SmallPig';
describe('SmallPig', () => {
let scene: MockScene;
beforeEach(() => {
scene = new MockScene();
});
describe('initialization', () => {
test('should create pig with correct health', () => {
const pig = new SmallPig(scene, 100, 200);
expect(pig.health).toBe(10);
expect(pig.maxHealth).toBe(10);
});
test('should create pig with correct scale', () => {
const pig = new SmallPig(scene, 100, 200);
expect(pig.body).toBeDefined();
});
});
describe('damage system', () => {
test('should reduce health when damaged', () => {
const pig = new SmallPig(scene, 100, 200);
pig.takeDamage(5);
expect(pig.health).toBe(5);
});
test('should be destroyed when health reaches 0', () => {
const pig = new SmallPig(scene, 100, 200);
const destroyedSpy = vi.fn();
scene.on('pig-destroyed', destroyedSpy);
pig.takeDamage(10);
expect(pig.isDestroyed()).toBe(true);
expect(destroyedSpy).toHaveBeenCalledWith(pig);
});
});
describe('visual damage state', () => {
test('should show damage when health > 50% lost', () => {
const pig = new SmallPig(scene, 100, 200);
pig.takeDamage(6);
expect(pig.shouldShowDamageState()).toBe(true);
});
});
});
Testing Game Systems
Testing PigHealthSystem
import { describe, test, expect, beforeEach, vi } from 'vitest';
import { MockScene } from '../helpers/MockScene';
import { PigHealthSystem } from '@/systems/PigHealthSystem';
import { SmallPig } from '@/objects/pigs/SmallPig';
describe('PigHealthSystem', () => {
let scene: MockScene;
let system: PigHealthSystem;
beforeEach(() => {
scene = new MockScene();
system = new PigHealthSystem(scene);
});
test('should track all pigs in scene', () => {
const pig1 = new SmallPig(scene, 100, 200);
const pig2 = new SmallPig(scene, 300, 200);
system.trackPig(pig1);
system.trackPig(pig2);
expect(system.trackedPigCount).toBe(2);
});
test('should emit event when all pigs destroyed', () => {
const pig = new SmallPig(scene, 100, 200);
system.trackPig(pig);
const allDestroyedSpy = vi.fn();
scene.on('all-pigs-destroyed', allDestroyedSpy);
pig.takeDamage(10);
expect(allDestroyedSpy).toHaveBeenCalled();
});
});
Testing Bird Abilities
import { describe, test, expect, beforeEach } from 'vitest';
import { MockScene } from '../../helpers/MockScene';
import { YellowBird } from '@/objects/birds/YellowBird';
describe('YellowBird', () => {
let scene: MockScene;
let bird: YellowBird;
beforeEach(() => {
scene = new MockScene();
bird = new YellowBird(scene, 100, 200);
});
describe('speed boost ability', () => {
test('should increase velocity by 2.5x when activated', () => {
bird.body.velocity = { x: 10, y: 0 };
bird.activateAbility();
expect(bird.body.velocity.x).toBe(25);
});
test('should emit ability-activated event', () => {
const spy = vi.fn();
scene.on('ability-activated', spy);
bird.activateAbility();
expect(spy).toHaveBeenCalledWith('yellow-boost', expect.any(Object));
});
});
});
Testing Matter.js Physics
import { describe, test, expect, beforeEach } from 'vitest';
import { MockScene } from '../../helpers/MockScene';
import { Block } from '@/objects/Block';
describe('Block Collision', () => {
let scene: MockScene;
beforeEach(() => {
scene = new MockScene();
});
test('should detect collision between two blocks', () => {
const block1 = new Block(scene, 100, 100, 'wood');
const block2 = new Block(scene, 100, 100, 'glass');
block1.onCollide(block2);
expect(block1.hasCollided).toBe(true);
});
test('should transfer momentum on collision', () => {
const block1 = new Block(scene, 100, 100, 'wood');
const block2 = new Block(scene, 100, 100, 'glass');
block1.body.velocity = { x: 10, y: 0 };
block2.body.velocity = { x: 0, y: 0 };
block1.onCollide(block2);
expect(block2.body.velocity.x).toBeGreaterThan(0);
});
});
Test Organization
File Structure
tests/
├── helpers/
│ └── MockScene.ts # Shared mock implementation
├── unit/
│ ├── objects/
│ │ ├── birds/
│ │ │ ├── RedBird.test.ts
│ │ │ ├── YellowBird.test.ts
│ │ │ └── BlackBird.test.ts
│ │ ├── pigs/
│ │ │ ├── Pig.test.ts
│ │ │ ├── SmallPig.test.ts
│ │ │ └── LargePig.test.ts
│ │ └── blocks/
│ │ └── Block.test.ts
│ └── systems/
│ ├── PigHealthSystem.test.ts
│ ├── BirdAbilityManager.test.ts
│ └── ScoringSystem.test.ts
Common Issues and Solutions
| Issue | Cause | Solution |
|---|
| "scene.init is not a function" | Using real Scene instead of MockScene | Replace with MockScene |
| "Cannot read properties of undefined" | Missing mock properties | Add missing property to MockScene |
| Event handlers not being called | Event system not mocked | Implement events.on/off/emit in MockScene |
| Tween-related failures | Tweens not mocked | Add tweens.add mock to MockScene |
| "matter.add is not a function" | Matter.js not mocked | Add matter.add mock to MockScene |
Best Practices
- Always use MockScene - Never instantiate real Phaser.Scene in unit tests
- Mock all scene properties used - If code uses
scene.time.addEvent, mock it
- Test behavior, not Phaser internals - Test game logic, not framework calls
- Use beforeEach for clean state - Create fresh MockScene for each test
- Clean up event listeners - Remove listeners in test cleanup
- Test edge cases - Zero health, max health, boundary conditions
- Verify event emissions - Ensure game events fire correctly
Anti-Patterns
❌ DON'T:
import { Game, Scene } from 'phaser';
test('should create pig', () => {
const game = new Game(config);
const scene = new Scene();
game.scene.add('test', scene);
});
✅ DO:
import { MockScene } from '../helpers/MockScene';
test('should create pig', () => {
const scene = new MockScene();
const pig = new SmallPig(scene, 100, 200);
expect(pig.health).toBe(10);
});
References
LESSON LEARNED FROM BUG-011: The MockScene pattern is essential for testing Phaser objects. Always create comprehensive mocks for all Phaser Scene properties used by your code. This prevents test failures caused by missing Phaser Game instances.
UI Component Testing Patterns (feat-027)
Lesson from feat-027 (Star Rating Preview): UI components with animations require specific testing patterns.
Testing UI Update Methods
import { describe, test, expect, beforeEach, vi } from 'vitest';
import { MockScene } from '../helpers/MockScene';
import { HUD } from '@/ui/HUD';
describe('HUD Star Rating Preview', () => {
let scene: MockScene;
let hud: HUD;
beforeEach(() => {
scene = new MockScene();
hud = new HUD(scene);
});
describe('star threshold calculation', () => {
test('should calculate 2-star threshold correctly per level', () => {
expect(hud.getTwoStarThreshold(1)).toBe(34000);
expect(hud.getTwoStarThreshold(5)).toBe(42000);
});
test('should calculate 3-star threshold correctly per level', () => {
expect(hud.getThreeStarThreshold(1)).toBe(72000);
expect(hud.getThreeStarThreshold(10)).toBe(126000);
});
});
describe('star fill state updates', () => {
test('should show first star filled when score exceeds first threshold', () => {
hud.updateStarRating(10000, 1);
expect(hud.stars[0].tintTopLeft).toBe(0xcccccc);
expect(hud.stars[0].alpha).toBeLessThan(0.5);
});
test('should show second star filled when score exceeds threshold', () => {
hud.updateStarRating(35000, 1);
expect(hud.stars[0].tintTopLeft).toBe(0xffcc00);
expect(hud.stars[0].alpha).toBe(1.0);
});
});
describe('animation behavior', () => {
test('should create tween when star state changes', () => {
const tweenSpy = vi.fn();
scene.tweens.add = tweenSpy;
hud.updateStarRating(0, 1);
hud.updateStarRating(35000, 1);
expect(tweenSpy).toHaveBeenCalled();
});
test('should kill existing tweens before creating new ones', () => {
const killSpy = vi.fn();
scene.tweens.killTweensOf = killSpy;
hud.updateStarRating(10000, 1);
hud.updateStarRating(35000, 1);
expect(killSpy).toHaveBeenCalled();
});
});
});
Testing Real-time Updates
describe('real-time score updates', () => {
test('should update star display as score increases', () => {
const updateSpy = vi.fn();
hud.on('star-updated', updateSpy);
hud.updateStarRating(10000, 1);
hud.updateStarRating(20000, 1);
hud.updateStarRating(34000, 1);
expect(updateSpy).toHaveBeenCalledTimes(3);
});
test('should handle rapid score updates efficiently', () => {
const startTime = performance.now();
for (let i = 0; i < 60; i++) {
hud.updateStarRating(i * 100, 1);
}
const duration = performance.now() - startTime;
expect(duration).toBeLessThan(16);
});
});
Test Coverage Checklist for UI Components
Common UI Testing Issues
| Issue | Symptoms | Solution |
|---|
| Tweens accumulate | Frame drops over time | Kill existing tweens before creating new ones |
| State not updating | Visuals don't change | Verify event listeners are attached |
| Thresholds wrong | Stars fill at wrong scores | Test calculation with multiple level values |
| Performance drops | Laggy UI | Don't tween on every frame, only on state change |