Unit Testing eines Stateful Frameworks wie Phaser?

9

TL; DR Ich benötige Hilfe bei der Identifizierung von Techniken zur Vereinfachung des automatisierten Komponententests, wenn ich in einem zustandsbehafteten Rahmen arbeite.


Hintergrund:

Ich schreibe gerade ein Spiel in TypeScript und dem Phaser-Framework . Phaser beschreibt sich selbst als ein HTML5-Spielframework, das so wenig wie möglich versucht, die Struktur Ihres Codes einzuschränken. Dies bringt einige Nachteile mit sich , nämlich dass es ein Gott-Objekt Phaser.Game gibt, mit dem Sie auf alles zugreifen können: den Cache, die Physik, die Spielzustände und vieles mehr.

Diese Statefulness macht es wirklich schwierig, viele Funktionen wie meine Tilemap zu testen. Sehen wir uns ein Beispiel an:

Hier teste ich, ob meine Kachelschichten korrekt sind oder nicht, und kann die Wände und Kreaturen in meiner Tilemap identifizieren:

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

Egal was ich mache, sobald ich versuche, die Karte zu erstellen, ruft Phaser intern den Cache auf, der nur zur Laufzeit gefüllt wird.

Ich kann diesen Test nicht aufrufen, ohne das gesamte Spiel zu laden.

Eine komplexe Lösung könnte darin bestehen, einen Adapter oder Proxy zu schreiben, der die Karte nur erstellt, wenn sie auf dem Bildschirm angezeigt werden muss. Oder ich könnte das Spiel selbst füllen, indem ich nur die benötigten Assets manuell lade und es dann nur für die bestimmte Testklasse oder das jeweilige Modul verwende.

Ich entschied mich für eine meiner Meinung nach pragmatischere, aber fremde Lösung. Zwischen dem Laden meines Spiels und dem tatsächlichen Spielen habe ich ein TestStateIn eingeblendet , das den Test mit allen bereits geladenen Assets und zwischengespeicherten Daten ausführt .

Das ist cool, weil ich alle gewünschten Funktionen testen kann, aber auch uncool, weil dies ein technischer Integrationstest ist und man sich fragt, ob ich nicht einfach auf den Bildschirm schauen und sehen kann, ob die Feinde angezeigt werden. Nein, sie wurden möglicherweise fälschlicherweise als Gegenstand identifiziert (ist bereits einmal aufgetreten), oder - später in den Tests - haben sie möglicherweise keine Ereignisse erhalten, die mit ihrem Tod verbunden sind.

Meine Frage - Ist Shimming in einem Testzustand wie diesem üblich? Gibt es bessere Ansätze, insbesondere in der JavaScript-Umgebung, die mir nicht bekannt sind?


Ein anderes Beispiel:

Okay, hier ist ein konkreteres Beispiel, um zu erklären, was passiert:

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

Ich konstruiere meine Tilemap aus drei Teilen:

  • Die Karten key
  • Die manifestDetaillierung aller Assets (Kacheln und Spritesheets), die von der Karte benötigt werden
  • A mapDefinition, das die Struktur und die Ebenen der Tilemap beschreibt.

Zuerst muss ich super aufrufen, um die Tilemap in Phaser zu erstellen. Dies ist der Teil, der alle diese Aufrufe zum Cache aufruft, wenn versucht wird, die tatsächlichen Assets und nicht nur die in der Liste definierten Schlüssel nachzuschlagen manifest.

Zweitens ordne ich die Kachelblätter und Kachelebenen der Tilemap zu. Es kann jetzt die Karte rendern.

Drittens, ich Iterierte durch meine Schichten und findet spezielle Objekte , die ich von der Karte zu extrudieren will: Creatures, Items, Interactablesund so weiter. Ich erstelle und speichere diese Objekte für die spätere Verwendung.

Ich habe derzeit noch eine relativ einfache API, mit der ich diese Entitäten finden, entfernen und aktualisieren kann:

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

Diese Funktionalität möchte ich überprüfen. Wenn ich die Kachelebenen oder Kachelsätze nicht hinzufüge, wird die Karte nicht gerendert, aber ich kann sie möglicherweise testen. Selbst wenn Sie super (...) aufrufen, wird eine kontextspezifische oder zustandsbehaftete Logik aufgerufen, die ich in meinen Tests nicht isolieren kann.

IAE
quelle
2
Ich bin verwirrt. Versuchen Sie zu testen, ob Phaser die Tilemap lädt, oder versuchen Sie, den Inhalt der Tilemap selbst zu testen? Wenn es das erstere ist, testen Sie im Allgemeinen nicht, ob Ihre Abhängigkeiten ihre Aufgabe erfüllen. Das ist die Aufgabe des Bibliotheksverwalters. In letzterem Fall ist Ihre Spiellogik zu eng an das Framework gekoppelt. So viel die Leistung zulässt, möchten Sie das Innenleben Ihres Spiels rein halten und die Nebenwirkungen den obersten Ebenen des Programms überlassen, um diese Art von Chaos zu vermeiden.
Doval
Nein, ich teste meine eigene Funktionalität. Es tut mir leid, wenn die Tests nicht so aussehen, aber es geht ein bisschen unter die Decke. Im Wesentlichen schaue ich durch die Tilemap und entdecke spezielle Kacheln, die ich in Spielentitäten wie Gegenstände, Kreaturen usw. konvertiere. Diese Logik gehört mir und muss auf jeden Fall getestet werden.
IAE
1
Können Sie erklären, wie genau Phaser dann daran beteiligt ist? Mir ist nicht klar, wo und warum Phaser angerufen wird. Woher kommt die Karte?
Doval
Die Verwirrung tut mir leid! Ich habe meinen Tilemap-Code als Beispiel für eine Funktionseinheit hinzugefügt, die ich testen möchte. Tilemap ist eine Erweiterung (oder optional eine) Phaser.Tilemap, mit der ich die Tilemap mit einer Reihe zusätzlicher Funktionen rendern kann, die ich verwenden möchte. Der letzte Absatz zeigt, warum ich es nicht isoliert testen kann. Sogar als Komponente, in dem Moment, in dem ich gerade new Tilemap(...)Phaser anfange, in seinem Cache zu graben. Ich müsste das verschieben, aber das bedeutet, dass sich meine Tilemap in zwei Zuständen befindet, einem, der sich nicht richtig rendern kann, und dem vollständig erstellten.
IAE
Es scheint mir, dass, wie ich in meinem ersten Kommentar sagte, Ihre Spielelogik zu stark an das Framework gekoppelt ist. Sie sollten in der Lage sein, Ihre Spielelogik auszuführen, ohne das Framework überhaupt einzubringen. Das Koppeln der Kachelkarte mit den Assets, mit denen sie auf dem Bildschirm gezeichnet wurde, steht im Weg.
Doval

Antworten:

2

Da ich Phaser oder Typescipt nicht kenne, versuche ich immer noch, Ihnen eine Antwort zu geben, da die Probleme, mit denen Sie konfrontiert sind, Probleme sind, die auch bei vielen anderen Frameworks sichtbar sind. Das Problem ist, dass Komponenten zu eng miteinander verbunden sind (alles zeigt auf das Gott-Objekt, und das Gott-Objekt besitzt alles ...). Dies war unwahrscheinlich, wenn die Entwickler des Frameworks selbst Unit-Tests erstellten.

Grundsätzlich haben Sie vier Möglichkeiten:

  1. Beenden Sie den Unit-Test.
    Diese Optionen sollten nicht ausgewählt werden, es sei denn, alle anderen Optionen schlagen fehl.
  2. Wählen Sie ein anderes Framework oder schreiben Sie Ihr eigenes.
    Die Wahl eines anderen Frameworks, das Unit-Tests verwendet und die Kopplung verliert, erleichtert das Leben erheblich. Aber vielleicht gibt es keine, die Sie mögen, und deshalb bleiben Sie bei dem Rahmen, den Sie jetzt haben. Das Schreiben eigener kann viel Zeit in Anspruch nehmen.
  3. Tragen Sie zum Framework bei und machen Sie es testfreundlich.
    Wahrscheinlich am einfachsten, aber es hängt wirklich davon ab, wie viel Zeit Sie haben und wie bereit die Entwickler des Frameworks sind, Pull-Anfragen anzunehmen.
  4. Wickeln Sie das Framework ein.
    Diese Option ist wahrscheinlich die beste Option, um mit Unit-Tests zu beginnen. Wickeln Sie bestimmte Objekte, die Sie wirklich benötigen, in die Unit-Tests ein und erstellen Sie für den Rest gefälschte Objekte.
David Perfors
quelle
2

Wie David bin ich nicht mit Phaser oder Typescript vertraut, aber ich erkenne Ihre Bedenken als häufig bei Unit-Tests mit Frameworks und Bibliotheken an.

Die kurze Antwort lautet: Ja, Shimmen ist die richtige und übliche Methode, um dies mit Unit-Tests zu tun . Ich denke, die Trennung versteht den Unterschied zwischen isolierten Unit-Tests und Funktionstests.

Unit-Tests zeigen, dass kleine Abschnitte Ihres Codes korrekte Ergebnisse liefern. Das Ziel eines Komponententests umfasst nicht das Testen des Codes von Drittanbietern. Es wird davon ausgegangen, dass der Code bereits getestet wurde, um wie vom Drittanbieter erwartet zu funktionieren. Wenn Sie einen Komponententest für Code schreiben, der auf einem Framework basiert, ist es üblich, bestimmte Abhängigkeiten zu shimen, um einen bestimmten Status für den Code vorzubereiten, oder das Framework / die Bibliothek vollständig zu shimen. Ein einfaches Beispiel ist die Sitzungsverwaltung für eine Website: Möglicherweise gibt der Shim immer einen gültigen, konsistenten Status zurück, anstatt aus dem Speicher zu lesen. Ein weiteres häufiges Beispiel ist das Shimmen von Daten im Speicher und das Umgehen von Bibliotheken, die eine Datenbank abfragen würden, da das Ziel nicht darin besteht, die Datenbank oder die Bibliothek, mit der Sie eine Verbindung herstellen, zu testen, sondern nur, dass Ihr Code Daten korrekt verarbeitet.

Gute Unit-Tests bedeuten jedoch nicht, dass der Endbenutzer genau das sieht, was Sie erwarten. Funktionstests zeigen eher auf hoher Ebene, dass eine ganze Funktion funktioniert, Frameworks und alles. Zurück zum Beispiel einer einfachen Website: Bei einem Funktionstest wird möglicherweise eine Webanforderung an Ihren Code gesendet und die Antwort auf gültige Ergebnisse überprüft. Es erstreckt sich über den gesamten Code, der zur Erzielung von Ergebnissen erforderlich ist. Der Test dient mehr der Funktionalität als der spezifischen Code-Korrektheit.

Ich denke, Sie sind mit Unit-Tests auf dem richtigen Weg. Um Funktionstests des gesamten Systems hinzuzufügen, würde ich separate Tests erstellen, die die Phaser-Laufzeit aufrufen und die Ergebnisse überprüfen.

Matt S.
quelle