Unit Testing di un framework stateful come Phaser?


9

TL; DR Ho bisogno di aiuto per identificare le tecniche per semplificare i test di unità automatizzati quando lavoro 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. Questo comporta alcuni compromessi, vale a dire che esiste un oggetto Phaser.Game che ti consente di accedere a tutto: cache, fisica, stati di gioco e altro ancora.

Questa statualità rende davvero difficile testare molte funzionalità, come il mio Tilemap. Vediamo un esempio:

Qui sto testando se i miei strati di piastrella sono corretti e posso identificare i muri e le creature all'interno del mio 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]
            ]);

Indipendentemente da ciò 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 quella di scrivere un adattatore o un proxy che costruisca la mappa solo quando è necessario visualizzarla sullo schermo. Oppure potrei popolare il gioco caricando manualmente solo le risorse di cui ho bisogno e quindi utilizzandolo solo per la classe o il modulo di test specifici.

Ho scelto quella che ritengo sia una soluzione più pragmatica, ma estranea a questo. Tra il caricamento del mio gioco e il suo effettivo gioco, ho evitato un TestStatein che esegue il test con tutte le risorse e i dati memorizzati nella cache già caricati.

Questo è bello, perché posso testare tutte le funzionalità che desidero, ma anche sregolate, perché si tratta di un test di integrazione tecnico 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 un Articolo (già accaduto una volta) o, in seguito ai test, potrebbero non aver ricevuto eventi legati alla loro morte.

La mia domanda : lo shimming in uno stato di test come questo è comune? Esistono approcci migliori, soprattutto in ambiente JavaScript, di cui non sono a conoscenza?


Un altro esempio:

Ok, ecco un esempio più concreto per 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 il mio Tilemap da tre parti:

  • La mappa è key
  • Il manifestdettaglio di tutte le risorse (fogli di piastrelle e fogli di calcolo) richiesti dalla mappa
  • A mapDefinitionche descrive la struttura e i livelli della piastrella.

Innanzitutto, devo chiamare super per costruire il Tilemap all'interno di Phaser. Questa è la parte che invoca tutte quelle chiamate alla cache mentre tenta di cercare le risorse effettive e non solo le chiavi definite in manifest.

In secondo luogo, associo i fogli di piastrelle e i livelli di piastrelle con il Tilemap. Ora può eseguire il rendering della mappa.

In terzo luogo, mi iterare attraverso i miei livelli e trovare tutti gli oggetti speciali che voglio estrusione dalla mappa: Creatures, Items, Interactablese così via. Creo e memorizzo questi oggetti per un uso successivo.

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 layer delle tessere o i set di tessere, la mappa non verrebbe visualizzata, ma potrei essere in grado di provarla. Tuttavia, anche chiamare super (...) invoca logiche specifiche del contesto o stateful che non riesco a isolare nei miei test.


2
Non ho capito bene. Stai provando a provare che Phaser sta facendo il suo lavoro nel caricare la tilemap o stai provando a testare il contenuto della stessa tilemap? Se è il primo, generalmente non testate che le vostre dipendenze facciano il loro lavoro; questo è il lavoro del manutentore della biblioteca. In quest'ultimo caso, la logica di gioco è troppo strettamente accoppiata al framework. Per quanto le prestazioni lo consentano, vuoi mantenere puro il funzionamento interno del tuo gioco e lasciare gli effetti collaterali ai livelli più alti del programma per evitare questo tipo di casino.
Doval,

No, sto testando la mia funzionalità. Mi dispiace se i test non sembrano così, ma c'è un po 'sotto le coperte. In sostanza, sto guardando attraverso il tilemap e scoprendo tessere speciali che converto in entità di gioco come Oggetti, Creature e così via. Questa logica è tutta mia e deve certamente essere testata.
IAE,

1
Puoi spiegare in che modo esattamente Phaser è coinvolto in questo allora? Non mi è chiaro dove venga invocato Phaser e perché. Da dove viene la mappa?
Doval,

Mi dispiace per la confusione! Ho aggiunto il mio codice Tilemap come esempio di un'unità di funzionalità che sto cercando di testare. Tilemap è un'estensione (o facoltativamente ha-a) Phaser.Tilemap che mi permette di renderizzare il tilemap con una serie di funzionalità extra che mi piacerebbe usare. L'ultimo paragrafo evidenzia perché non riesco a provarlo da solo. Anche come componente, nel momento in cui ho appena new Tilemap(...)Phaser inizia a scavare nella sua cache. Dovrei rimandarlo, ma ciò significa che il mio Tilemap è in due stati, uno che non può essere riprodotto correttamente e quello completamente costruito.
IAE

Mi sembra che, come ho detto nel mio primo commento, la tua logica di gioco sia troppo abbinata al framework. Dovresti essere in grado di eseguire la logica di gioco senza introdurre affatto il framework. L'accoppiamento della mappa delle tessere alle risorse utilizzate per disegnarla sullo schermo si sta facendo strada.
Doval,

Risposte:


2

Non conoscendo Phaser o Typescipt, provo ancora a darti una risposta, perché i problemi che stai affrontando sono problemi che sono anche visibili con molti altri framework. Il problema è che i componenti devono essere strettamente accoppiati (tutto indica l'oggetto Dio e l'oggetto Dio possiede tutto ...). Questo è qualcosa che è improbabile che accada se i creatori del framework creano da soli test unitari.

Fondamentalmente hai quattro opzioni:

  1. Interrompere il test unitario.
    Questa opzione non deve essere scelta, a meno che tutte le altre opzioni non vadano a buon fine.
  2. Scegli un altro framework o scrivi il tuo.
    Scegliere un altro framework che utilizza unit test 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 framework che hai ora. Scrivere il tuo può richiedere molto tempo.
  3. Contribuire al framework e renderlo test amichevole.
    Probabilmente il più semplice da fare, ma dipende davvero da quanto tempo hai e dalla volontà dei creatori del framework di accettare richieste pull.
  4. Avvolgere il quadro.
    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.

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 i test unitari . Penso che la disconnessione comprenda la differenza tra test unitari isolati e test funzionali.

I test unitari dimostrano che piccole sezioni del codice producono risultati corretti. L'obiettivo di un test unitario non include il test di codice di terze parti. Il presupposto è che il codice sia già testato per funzionare come previsto dalla terza parte. Quando si scrive un test unitario per il codice che si basa su un framework, è comune evitare determinate dipendenze per preparare al codice ciò che assomiglia a uno stato particolare o shim 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 invece di leggere dalla memoria. Un altro esempio comune è lo shimming dei dati in memoria e l'esclusione di qualsiasi libreria che richiederebbe un database, poiché l'obiettivo non è testare il database o la libreria che si sta utilizzando per connettersi ad esso, solo che il codice elabora i dati correttamente.

Ma un buon test unitario non significa che l'utente finale vedrà esattamente ciò che ti aspetti. I test funzionali hanno una visione più di alto livello sul funzionamento di un'intera funzionalità, dei framework e di tutto. Tornando all'esempio di un semplice sito Web, un test funzionale potrebbe effettuare una richiesta Web al codice e verificare la risposta per risultati validi. Si estende su tutto il codice necessario per produrre risultati. Il test è per 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 di tutto il sistema, creerei test separati che invocano il runtime di Phaser e controllano i risultati.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.