Unit Test di un framework stateful come Phaser?

9

TL; DR Ho bisogno di aiuto per identificare le tecniche per semplificare i test unitari automatizzati quando si lavora all'interno di un framework stateful.

Sfondo:

Attualmente sto scrivendo un gioco in TypeScript e il framework Phaser . Phaser si descrive come un framework di gioco HTML5 che cerca il meno possibile di limitare la struttura del codice. Ciò comporta alcuni compromessi, vale a dire che esiste un God-object Phaser.Game che ti consente di accedere tutto: cache, fisica, stati del gioco e altro.

Questo stato di cose rende molto difficile testare molte funzionalità, come la mia Tilemap. Vediamo un esempio:

Qui sto verificando se i miei riquadri sono o meno corretti e posso identificare i muri e le creature nella mia 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]
            ]);

Non importa quello che faccio, non appena provo a creare la mappa, Phaser invoca internamente la sua cache, che viene popolata solo durante il runtime.

Non posso invocare questo test senza caricare l'intero gioco.

Una soluzione complessa potrebbe essere scrivere un adattatore o un proxy che costruisce la mappa solo quando è necessario visualizzarlo sullo schermo. Oppure potrei compilare il gioco manualmente caricando solo le risorse di cui ho bisogno e quindi utilizzandolo solo per la specifica classe di test o modulo.

Ho scelto ciò che ritengo sia una soluzione più pragmatica, ma estranea a questo. Tra il caricamento del mio gioco e la sua esecuzione effettiva, ho eseguito il rendering di un TestState in cui viene eseguito il test con tutte le risorse e i dati memorizzati nella cache già caricati.

Questo è bello, perché posso testare tutte le funzionalità che voglio, ma anche uncool, perché questo è un test di integrazione tecnica e ci si chiede se non potrei semplicemente guardare lo schermo e vedere se i nemici sono visualizzati. In realtà, no, potrebbero essere stati erroneamente identificati come Item (successo già una volta) o, più tardi nei test, non avrebbero potuto ricevere eventi legati alla loro morte.

La mia domanda - Lo shimming in uno stato di test come questo comune? Ci sono approcci migliori, specialmente nell'ambiente JavaScript, di cui non sono a conoscenza?

Un altro esempio:

Ok, ecco un esempio più concreto per aiutare a spiegare cosa sta succedendo:

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

Costruisco la mia Tilemap da tre parti:

  • La mappa key
  • Il manifest che riporta in dettaglio tutte le risorse (fogli di calcolo e fogli di calcolo) richiesti dalla mappa
  • Un mapDefinition che descrive la struttura e i livelli della tilemap.

Per prima cosa, devo chiamare super per costruire la Tilemap all'interno di Phaser. Questa è la parte che richiama tutte quelle chiamate alla cache mentre prova a cercare le risorse effettive e non solo le chiavi definite in manifest .

In secondo luogo, associo i fogli di piastrelle e i livelli di tessere con la Tilemap. Ora può rendere la mappa.

In terzo luogo, faccio scorrere i miei strati e trovo gli oggetti speciali che voglio estrudere dalla mappa: Creatures , Items , Interactables e così via. Creo e memorizzo questi oggetti per un uso futuro.

Al momento ho ancora un'API relativamente semplice che mi consente di trovare, rimuovere, aggiornare queste entità:

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

È questa funzionalità che voglio controllare. Se non aggiungo i Livelli Tile o Tileset, la mappa non verrà visualizzata, ma potrei riuscire a verificarla. Tuttavia, anche chiamando super (...) invoca una logica specifica del contesto o di stato che non riesco a isolare nei miei test.

    
posta IAE 04.12.2014 - 23:52
fonte

2 risposte

2

Non conoscendo Phaser o Typescipt, cerco comunque di darti una risposta, perché i problemi che stai affrontando sono problemi che sono visibili anche con molti altri framework. Il problema è che i componenti sono strettamente accoppiati (tutto indica l'oggetto di Dio, e l'oggetto di Dio possiede tutto ...). È improbabile che ciò accada se i creatori del framework hanno creato test di unità stessi.

Fondamentalmente hai quattro opzioni:

  1. Arresta il test dell'unità.
    Questa opzione non dovrebbe essere scelta, a meno che tutte le altre opzioni non vadano a buon fine.
  2. Scegli un altro quadro o scrivi il tuo.
    La scelta di un altro framework che utilizza il test delle unità e ha perso l'accoppiamento, renderà la vita molto più semplice. Ma forse non c'è nessuno che ti piace e quindi sei bloccato con il quadro che hai ora. Scrivere il tuo può richiedere molto tempo.
  3. Contribuire al framework e renderlo amichevole Probabilmente è il più facile da fare, ma dipende in realtà da quanto tempo hai e da quanto i creatori del framework sono disposti ad accettare richieste di pull.
  4. Avvolgere il framework.
    Questa opzione è probabilmente l'opzione migliore per iniziare con i test unitari. Avvolgi alcuni oggetti di cui hai veramente bisogno nei test unitari e crea oggetti falsi per il resto.
risposta data 16.02.2016 - 09:01
fonte
2

Come David, non ho familiarità con Phaser o Typescript, ma riconosco le tue preoccupazioni come comuni ai test unitari con framework e librerie.

La risposta breve è sì, lo shimming è il modo corretto e comune per gestirlo con il test dell'unità . Penso che la disconnessione stia capendo la differenza tra test delle unità isolate e test funzionali.

Test dell'unità dimostra che piccole sezioni del codice producono risultati corretti. L'obiettivo di un test unitario non include il test del codice di terze parti. L'ipotesi è che il codice sia già testato per funzionare come previsto dalla terza parte. Quando si scrive un test unitario per il codice che fa affidamento su un framework, è comune attenuare determinate dipendenze per preparare ciò che appare come uno stato particolare per il codice o per schiacciare interamente il framework / libreria. Un semplice esempio è la gestione delle sessioni per un sito Web: forse lo shim restituisce sempre uno stato valido e coerente anziché leggere dallo storage. Un altro esempio comune è lo shimming dei dati in memoria e l'esclusione di qualsiasi libreria che richiederebbe un database, perché l'obiettivo non è quello di testare il database o la libreria che si sta utilizzando per connettersi ad esso, solo che il codice elabora i dati correttamente. / p>

Ma un buon test dell'unità non significa che l'utente finale vedrà esattamente ciò che si aspetta. Test funzionali richiede più di una vista di alto livello su un'intera funzionalità, quadri e tutto. Tornando all'esempio di un semplice sito Web, un test funzionale potrebbe effettuare una richiesta Web al codice e controllare la risposta per ottenere risultati validi. Si estende su tutto il codice necessario per produrre risultati. Il test è per la funzionalità più che per la correttezza del codice specifico.

Quindi penso che tu sia sulla strada giusta con i test unitari. Per aggiungere test funzionali all'intero sistema creerei test separati che richiamano il runtime di Phaser e controllano i risultati.

    
risposta data 25.02.2016 - 17:40
fonte

Leggi altre domande sui tag