TL;DR I need help in identifying techniques to simplify automated unit testing when working within a stateful framework.
Background:
I'm currently writing a game in TypeScript and the Phaser framework. Phaser describes itself as an HTML5 game framework that tries as little as possible to restrict the structure of your code. This comes with a few trade-offs, namely that there exists a God-object Phaser.Game which lets you access everything: the cache, physics, game states, and more.
This statefulness makes it really hard to test a lot of functionality, such as my Tilemap. Let's see an example:
Here I am testing whether or not my tile layers correctly and I can identify the walls and creatures within my Tilemap:
export class TilemapTest extends tsUnit.TestClass {
constructor() {
super();
this.map = this.mapLoader.load("maze", this.manifest, this.mazeMapDefinition);
this.parameterizeUnitTest(this.isWall,
[
[{ x: 0, y: 0 }, true],
[{ x: 1, y: 1 }, false],
[{ x: 1, y: 0 }, true],
[{ x: 0, y: 1 }, true],
[{ x: 2, y: 0 }, false],
[{ x: 1, y: 3 }, false],
[{ x: 6, y: 3 }, false]
]);
this.parameterizeUnitTest(this.isCreature,
[
[{ x: 0, y: 0 }, false],
[{ x: 2, y: 0 }, false],
[{ x: 1, y: 3 }, true],
[{ x: 4, y: 1 }, false],
[{ x: 8, y: 1 }, true],
[{ x: 11, y: 2 }, false],
[{ x: 6, y: 3 }, false]
]);
No matter what I do, as soon as I try to create the map, Phaser internally invokes it's cache, which is only populated during runtime.
I cannot invoke this test without loading the entire game.
A complex solution might be to write an Adapter or Proxy that only builds the map when we need to display it on screen. Or I could populate the game myself by manually loading only the assets I need and then using it only for the specific test class or module.
I chose what I feel is a more pragmatic, but foreign solution to this. Between the loading of my game and the actual playing of it, I shimmed a TestState
in that runs the test with all assets and cached data already loaded.
This is cool, because I can test all of the functionality I want, but also uncool, because this is technical an integration test and one wonders whether I couldn't just look at the screen and see if the enemies are displayed. Actually, no, they might have been misidentified as an Item (happened once already) or- later in the tests- they might not have been given events tied to their death.
My question - Is shimming in a test-state like this common? Are there better approaches, especially in the JavaScript environment, that I'm not aware of?
Another Example:
Okay, here's a more concrete example to help explain what's happening:
export class Tilemap extends Phaser.Tilemap {
// layers is already defined in Phaser.Tilemap, so we use tilemapLayers instead.
private tilemapLayers: TilemapLayers = {};
// A TileMap can have any number of layers, but
// we're only concerned about the existence of two.
// The collidables layer has the information about where
// a Player or Enemy can move to, and where he cannot.
private CollidablesLayer = "Collidables";
// Triggers are map events, anything from loading
// an item, enemy, or object, to triggers that are activated
// when the player moves toward it.
private TriggersLayer = "Triggers";
private items: Array<Phaser.Sprite> = [];
private creatures: Array<Phaser.Sprite> = [];
private interactables: Array<ActivatableObject> = [];
private triggers: Array<Trigger> = [];
constructor(json: TilemapData) {
// First
super(json.game, json.key);
// Second
json.tilesets.forEach((tileset) => this.addTilesetImage(tileset.name, tileset.key), this);
json.tileLayers.forEach((layer) => {
this.tilemapLayers[layer.name] = this.createLayer(layer.name);
}, this);
// Third
this.identifyTriggers();
this.tilemapLayers[this.CollidablesLayer].resizeWorld();
this.setCollisionBetween(1, 2, true, this.CollidablesLayer);
}
I construct my Tilemap from three parts:
- The map's
key
- The
manifest
detailing all assets (tilesheets and spritesheets) required by the map - A
mapDefinition
that describes the tilemap's structure and layers.
First, I must call super to construct the Tilemap within Phaser. This is the part that invokes all those calls to cache as it tries to look up the actual assets and not just the keys defined in the manifest
.
Second, I associate the tilesheets and tile layers with the Tilemap. It can now render the map.
Third, I iterate through my layers and find any special objects that I want to extrude from the map: Creatures
, Items
, Interactables
and so forth. I create and store these objects for later use.
I currently still have a relatively simple API that lets me find, remove, update these entities:
wallAt(at: TileCoordinates) {
var tile = this.getTile(at.x, at.y, this.CollidablesLayer);
return tile && tile.index != 0;
}
itemAt(at: TileCoordinates) {
return _.find(this.items, (item: Phaser.Sprite) => _.isEqual(this.toTileCoordinates(item), at));
}
interactableAt(at: TileCoordinates) {
return _.find(this.interactables, (object: ActivatableObject) => _.isEqual(this.toTileCoordinates(object), at));
}
creatureAt(at: TileCoordinates) {
return _.find(this.creatures, (creature: Phaser.Sprite) => _.isEqual(this.toTileCoordinates(creature), at));
}
triggerAt(at: TileCoordinates) {
return _.find(this.triggers, (trigger: Trigger) => _.isEqual(this.toTileCoordinates(trigger), at));
}
getTrigger(name: string) {
return _.find(this.triggers, { name: name });
}
It's this functionality I want to check. If I don't add the Tile Layers or Tilesets, the map wouldn't render, but I might be able to test it. However, even calling super(...) invokes context-specific or stateful logic that I cannot isolate in my tests.
new Tilemap(...)
Phaser starts digging in its cache. I'd have to defer that, but that means my Tilemap is in two states, one that can't render itself properly, and the fully constructed one.