对有状态的框架(例如Phaser)进行单元测试?


9

TL; DR在确定有状态的框架中工作时,我需要帮助您确定简化自动化单元测试的技术。


背景:

我目前正在用TypeScript和Phaser框架编写游戏。Phaser将自己描述为一个HTML5游戏框架,该框架试图尽可能少地限制代码的结构。这需要进行一些权衡,即存在一个上帝对象的Phaser.Game,它可以让您访问所有内容:缓存,物理,游戏状态等。

这种状态性使得很难测试很多功能,例如我的Tilemap。让我们来看一个例子:

在这里,我正在测试我的瓷砖图层是否正确,并且可以在我的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]
            ]);

无论我做什么,只要尝试创建地图,Phaser都会在内部调用它的缓存,该缓存仅在运行时填充。

如果不加载整个游戏,则无法调用此测试。

一个复杂的解决方案可能是编写仅在需要在屏幕上显示地图时才构建地图的适配器或代理。或者,我可以通过手动加载仅我需要的资产,然后仅将其用于特定的测试类或模块来自己填充游戏。

我选择了我认为更务实但外国的解决方案。在加载游戏和实际玩游戏之间,我填充了一个命令TestState,该命令将在已加载所有资产和缓存数据的情况下运行测试。

这很酷,因为我可以测试我想要的所有功能,但是又不酷,因为这是一项技术上的集成测试,一个人想知道我是否不能只看屏幕看看是否显示了敌人。实际上,不,它们可能被误认为一个项目(已经发生过一次),或者在测试的后期,可能没有被赋予与死亡相关的事件。

我的问题 -在这种常见的测试状态下匀场吗?是否有我不知道的更好的方法,尤其是在JavaScript环境中?


另一个例子:

好的,这是一个更具体的示例,可以帮助您解释正在发生的事情:

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);
    }

我从三个部分构造我的Tilemap:

  • 地图的 key
  • manifest由地图所需的细节全部资产(tilesheets和spritesheets)
  • 一个mapDefinition描述tilemap的的结构和层次。

首先,我必须调用super以在Phaser中构造Tilemap。这是在尝试查找实际资产而不是仅在中定义的键时调用所有这些缓存调用的部分manifest

其次,我将tilesheets和tile图层与Tilemap关联。现在可以渲染地图了。

第三,我迭代通过我的层和发现任何特殊对象,我想从地图挤压: CreaturesItemsInteractables等等。我创建并存储这些对象以供以后使用。

我目前仍然有一个相对简单的API,可让我查找,删除和更新这些实体:

    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 });
    }

我要检查的是此功能。如果不添加“平铺图层”或“平铺集”,则不会渲染地图,但我可以对其进行测试。但是,即使调用super(...)也会调用上下文特定或状态逻辑,这些逻辑无法在测试中隔离。


2
我糊涂了。您是要测试Phaser是否正在完成加载图块的工作,还是要测试图块本身的内容?如果是前者,则通常不会测试您的依赖项是否能发挥作用;那是图书馆维护者的工作。如果是后者,则您的游戏逻辑与框架紧密耦合。在性能允许的范围内,您希望保持游戏内部的纯净,并将副作用留在程序的最顶层以避免这种混乱。
2014年

不,我正在测试自己的功能。很抱歉,测试看起来不像这样,但是在幕后还有些不足。本质上,我正在浏览图块地图,发现了特殊的图块,并将其转换为游戏实体,例如物品,生物等。这种逻辑是我所有的,当然必须经过测试。
IAE 2014年

1
您能解释一下相位器到底有多精确吗?我不清楚移相器在哪里被调用以及为什么被调用。地图从哪里来?
Doval 2014年

对不起,我很困惑!我已经添加了Tilemap代码,作为我要测试的功能单元的示例。Tilemap是Phaser.Tilemap的扩展(或可选具有-a),它使我可以使用很多我想使用的额外功能来渲染tilemap。最后一段强调了为什么我不能孤立地对其进行测试。甚至作为一个组件,从我只是new Tilemap(...)Phaser开始挖掘其缓存的那一刻起。我不得不推迟,但这意味着我的Tilemap处于两种状态,一种状态无法正确呈现自身,另一种是完全构建的状态。
IAE 2014年

在我看来,就像我在第一句话中所说的那样,您的游戏逻辑与框架太过紧密了。您应该能够完全不引入框架就运行游戏逻辑。将图块地图与用于在屏幕上绘制图块的资产耦合起来很麻烦。
Doval 2014年

Answers:


2

我不知道Phaser或Typescipt,我仍然尝试给您一个答案,因为您面临的问题是很多其他框架也可以看到的问题。问题在于组件要紧密耦合(一切都指向上帝对象,而上帝对象拥有一切...)。如果框架的创建者自己创建了单元测试,那么这是不太可能发生的。

基本上,您有四个选择:

  1. 停止单元测试。
    除非所有其他选项均失败,否则不应选择此选项。
  2. 选择其他框架或编写自己的框架。
    选择另一个正在使用单元测试并且失去耦合的框架,将使工作变得更加轻松。但是也许没有您喜欢的东西,因此您陷于现在的框架中。自己编写可能需要很多时间。
  3. 为该框架做出贡献并使其易于测试。
    可能最容易做到,但这实际上取决于您有多少时间以及框架的创建者愿意接受拉取请求的方式。
  4. 包装框架。
    此选项可能是开始进行单元测试的最佳选择。包装单元测试中真正需要的某些对象,并为其余部分创建伪对象。

2

像David一样,我对Phaser或Typescript并不熟悉,但是我认识到您的担忧是使用框架和库进行单元测试所常见的。

简短的答案是肯定的,匀场处理是通过单元测试处理此问题的正确且常见的方法。我认为断开连接是了解隔离的单元测试和功能测试之间的区别。

单元测试证明代码的一小部分会产生正确的结果。单元测试的目标不包括测试第三方代码。假设代码已经过测试,可以按第三方的要求工作。在编写依赖于框架的代码的单元测试时,通常会填充某些依赖关系以准备看起来像代码的特定状态的对象,或者完全填充框架/库。一个简单的示例是网站的会话管理:也许填充程序总是返回有效的一致状态,而不是从存储中读取。另一个常见的示例是填充内存中的数据,并绕过任何会查询数据库的库,因为目标不是测试数据库或用于连接数据库的库,而是您的代码正确处理了数据。

但是良好的单元测试并不意味着最终用户将完全了解您的期望。 功能测试更多地从更高角度来看整个功能,框架以及所有功能都在工作。回到简单网站的示例,功能测试可能会向您的代码发出Web请求,并检查响应是否有效。它涵盖了产生结果所需的所有代码。测试的目的是针对功能,而不是针对特定的代码正确性。

因此,我认为您在单元测试方面正处于正确的轨道上。为了添加整个系统的功能测试,我将创建单独的测试,以调用Phaser运行时并检查结果。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.