Test unitaire d'un framework dynamique tel que Phaser?

9

TL; DR J'ai besoin d'aide pour identifier les techniques permettant de simplifier les tests unitaires automatisés lorsque je travaille dans un cadre dynamique.


Contexte:

J'écris actuellement un jeu en TypeScript et le framework Phaser . Phaser se décrit comme un framework de jeu HTML5 qui essaie le moins possible de restreindre la structure de votre code. Cela vient avec quelques compromis, à savoir qu'il existe un Dieu-objet Phaser.Game qui vous permet d'accéder à tout: le cache, la physique, les états de jeu, etc.

Cet état, il est vraiment difficile de tester de nombreuses fonctionnalités, telles que mon Tilemap. Voyons un exemple:

Ici, je teste si mes couches de tuiles sont correctes et je peux identifier les murs et les créatures dans mon 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]
            ]);

Peu importe ce que je fais, dès que j'essaie de créer la carte, Phaser invoque en interne son cache, qui n'est rempli que pendant l'exécution.

Je ne peux pas invoquer ce test sans charger l'intégralité du jeu.

Une solution complexe pourrait être d'écrire un adaptateur ou un proxy qui ne construit la carte que lorsque nous devons l'afficher à l'écran. Ou je pourrais remplir le jeu moi-même en chargeant manuellement uniquement les ressources dont j'ai besoin, puis en l'utilisant uniquement pour la classe ou le module de test spécifique.

J'ai choisi ce que j'estime être une solution plus pragmatique mais étrangère à cela. Entre le chargement de mon jeu et la lecture réelle de celui-ci, j'ai calé un TestStatequi exécute le test avec tous les actifs et les données en cache déjà chargés.

C'est cool, car je peux tester toutes les fonctionnalités que je veux, mais aussi pas cool, car c'est un test d'intégration technique et on se demande si je ne pourrais pas simplement regarder l'écran et voir si les ennemis sont affichés. En fait, non, ils pourraient avoir été mal identifiés comme un élément (déjà arrivé une fois) ou - plus tard dans les tests - ils pourraient ne pas avoir reçu d'événements liés à leur mort.

Ma question - Le calage dans un état de test comme celui-ci est-il courant? Y a-t-il de meilleures approches, en particulier dans l'environnement JavaScript, que je ne connais pas?


Un autre exemple:

D'accord, voici un exemple plus concret pour aider à expliquer ce qui se passe:

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

Je construis mon Tilemap à partir de trois parties:

  • Les cartes key
  • Le manifestdétail de tous les actifs (feuilles de tuiles et feuilles de sprites) requis par la carte
  • A mapDefinitionqui décrit la structure et les couches du tilemap.

Tout d'abord, je dois appeler super pour construire le Tilemap dans Phaser. C'est la partie qui appelle tous ces appels à mettre en cache pendant qu'elle essaie de rechercher les actifs réels et pas seulement les clés définies dans le manifest.

Deuxièmement, j'associe les feuilles et les couches de tuiles au Tilemap. Il peut maintenant rendre la carte.

Troisièmement, je itérer à travers mes couches et trouver des objets spéciaux que je veux expulsent de la carte: Creatures, Items, Interactableset ainsi de suite. Je crée et stocke ces objets pour une utilisation ultérieure.

J'ai actuellement encore une API relativement simple qui me permet de trouver, supprimer, mettre à jour ces entités:

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

C'est cette fonctionnalité que je veux vérifier. Si je n'ajoute pas les couches de tuiles ou les ensembles de tuiles, la carte ne sera pas rendue, mais je pourrai peut-être la tester. Cependant, même appeler super (...) invoque une logique contextuelle ou avec état que je ne peux pas isoler dans mes tests.

IAE
la source
2
Je suis confus. Essayez-vous de vérifier que Phaser fait son travail de chargement du tilemap ou essayez-vous de tester le contenu du tilemap lui-même? Si c'est le premier, vous ne testez généralement pas que vos dépendances font leur travail; c'est le travail du responsable de la bibliothèque. Dans ce dernier cas, votre logique de jeu est trop étroitement couplée au framework. Autant que les performances le permettront, vous voulez garder le fonctionnement interne de votre jeu pur et laisser les effets secondaires aux couches supérieures du programme pour éviter ce genre de gâchis.
Doval
Non, je teste ma propre fonctionnalité. Je suis désolé si les tests ne ressemblent pas à ça, mais il y en a un peu sous les couvertures. Essentiellement, je regarde le tilemap et découvre des tuiles spéciales que je convertis en entités de jeu telles que des objets, des créatures, etc. Cette logique est toute mienne et doit certainement être testée.
IAE
1
Pouvez-vous expliquer dans quelle mesure exactement Phaser est impliqué dans cela alors? Je ne sais pas clairement où Phaser est invoqué et pourquoi. D'où vient la carte?
Doval
Je suis désolé pour la confusion! J'ai ajouté mon code Tilemap comme exemple d'unité de fonctionnalité que j'essaie de tester. Tilemap est une extension (ou éventuellement a-a) Phaser.Tilemap qui me permet de rendre le tilemap avec un tas de fonctionnalités supplémentaires que j'aimerais utiliser. Le dernier paragraphe souligne pourquoi je ne peux pas le tester isolément. Même en tant que composant, au moment où je viens de new Tilemap(...)Phaser commence à creuser dans son cache. Je devrais reporter cela, mais cela signifie que mon Tilemap est dans deux états, celui qui ne peut pas se rendre correctement et celui entièrement construit.
IAE
Il me semble que, comme je l'ai dit dans mon premier commentaire, votre logique de jeu est trop couplée au framework. Vous devriez être en mesure d'exécuter votre logique de jeu sans apporter du tout le cadre. Le couplage de la carte de tuiles avec les actifs utilisés pour la dessiner à l'écran est gênant.
Doval

Réponses:

2

Ne connaissant pas Phaser ou Typescipt, j'essaie toujours de vous donner une réponse, car les problèmes que vous rencontrez sont des problèmes qui sont également visibles avec de nombreux autres cadres. Le problème est que les composants doivent être étroitement couplés (tout pointe vers l'objet Dieu, et l'objet Dieu possède tout ...). Il est peu probable que cela se produise si les créateurs du framework créent eux-mêmes des tests unitaires.

Fondamentalement, vous avez quatre options:

  1. Arrêtez les tests unitaires.
    Ces options ne doivent pas être choisies, sauf si toutes les autres options échouent.
  2. Choisissez un autre cadre ou écrivez le vôtre.
    Le choix d'un autre framework qui utilise des tests unitaires et qui a un couplage perdu, rendra la vie beaucoup plus facile. Mais peut-être n'y en a-t-il aucun que vous aimez et par conséquent vous êtes coincé avec le cadre que vous avez maintenant. Écrire le vôtre peut prendre beaucoup de temps.
  3. Contribuez au framework et rendez-le facile à tester.
    Probablement la plus simple à faire, mais cela dépend vraiment du temps dont vous disposez et de la volonté des créateurs du framework d'accepter les demandes d'extraction.
  4. Enveloppez le cadre.
    Cette option est probablement la meilleure option pour commencer les tests unitaires. Enveloppez certains objets dont vous avez vraiment besoin dans les tests unitaires et créez de faux objets pour le reste.
David Perfors
la source
2

Comme David, je ne connais pas Phaser ou Typescript, mais je reconnais que vos préoccupations sont communes aux tests unitaires avec les frameworks et les bibliothèques.

La réponse courte est oui, le calage est la manière correcte et courante de gérer cela avec des tests unitaires . Je pense que la déconnexion consiste à comprendre la différence entre les tests unitaires isolés et les tests fonctionnels.

Les tests unitaires prouvent que de petites sections de votre code produisent des résultats corrects. L'objectif d'un test unitaire n'inclut pas le test de code tiers. L'hypothèse est que le code est déjà testé pour fonctionner comme prévu par le tiers. Lors de l'écriture d'un test unitaire pour du code qui repose sur un framework, il est courant de shimer certaines dépendances pour préparer ce qui ressemble à un état particulier au code, ou de shimer le framework / la bibliothèque entièrement. Un exemple simple est la gestion de session pour un site Web: peut-être que le shim retourne toujours un état valide et cohérent au lieu de lire à partir du stockage. Un autre exemple courant est le shimming de données en mémoire et le contournement de toute bibliothèque qui interrogerait une base de données, car le but n'est pas de tester la base de données ou la bibliothèque que vous utilisez pour vous y connecter, juste que votre code traite les données correctement.

Mais un bon test unitaire ne signifie pas que l'utilisateur final verra exactement ce que vous attendez. Les tests fonctionnels prennent plus d'une vue de haut niveau qu'une fonctionnalité entière fonctionne, les cadres et tout. Pour revenir à l'exemple d'un site Web simple, un test fonctionnel peut faire une demande Web à votre code et vérifier la réponse pour des résultats valides. Il couvre tout le code requis pour produire des résultats. Le test concerne plus la fonctionnalité que l'exactitude du code spécifique.

Je pense donc que vous êtes sur la bonne voie avec les tests unitaires. Pour ajouter des tests fonctionnels de l'ensemble du système, je créerais des tests distincts qui invoqueraient le runtime Phaser et vérifieraient les résultats.

Matt S
la source