Unidad de prueba de un marco con estado como Phaser?


9

TL; DR Necesito ayuda para identificar técnicas para simplificar las pruebas unitarias automatizadas cuando trabajo dentro de un marco con estado.


Antecedentes:

Actualmente estoy escribiendo un juego en TypeScript y el marco Phaser . Phaser se describe a sí mismo como un marco de juego HTML5 que intenta lo menos posible para restringir la estructura de su código. Esto viene con algunas compensaciones, a saber, que existe un Phaser.Game de God-object que le permite acceder a todo: el caché, la física, los estados del juego y más.

Esta capacidad de estado hace que sea realmente difícil probar muchas funciones, como mi Tilemap. Veamos un ejemplo:

Aquí estoy probando si mis capas de mosaico son correctas o no y puedo identificar las paredes y las criaturas dentro de mi mapa de mosaico:

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

No importa lo que haga, tan pronto como intento crear el mapa, Phaser invoca internamente su caché, que solo se completa durante el tiempo de ejecución.

No puedo invocar esta prueba sin cargar todo el juego.

Una solución compleja podría ser escribir un Adaptador o Proxy que solo construya el mapa cuando necesitemos mostrarlo en la pantalla. O podría completar el juego yo mismo cargando manualmente solo los recursos que necesito y luego usándolo solo para la clase o módulo de prueba específico.

Elegí lo que siento es una solución más pragmática, pero extraña para esto. Entre la carga de mi juego y la reproducción real del juego, introduje una TestStateprueba que ejecuta la prueba con todos los activos y datos en caché ya cargados.

Esto es genial, porque puedo probar toda la funcionalidad que quiero, pero también no es genial, porque esta es una prueba técnica de integración y uno se pregunta si no podría simplemente mirar la pantalla y ver si se muestran los enemigos. En realidad, no, podrían haber sido identificados erróneamente como un artículo (ya sucedió una vez) o, más adelante en las pruebas, podrían no haber recibido eventos relacionados con su muerte.

Mi pregunta : ¿es común el shimming en un estado de prueba como este? ¿Hay mejores enfoques, especialmente en el entorno de JavaScript, que no conozco?


Otro ejemplo:

Bien, aquí hay un ejemplo más concreto para ayudar a explicar lo que está sucediendo:

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

Construyo mi Tilemap a partir de tres partes:

  • Los mapas key
  • El manifestdetalle de todos los activos (hojas de mosaico y hojas de sprites) requeridos por el mapa
  • A mapDefinitionque describe la estructura y las capas del mosaico.

Primero, debo llamar a super para construir el Tilemap dentro de Phaser. Esta es la parte que invoca todas esas llamadas a la memoria caché al intentar buscar los activos reales y no solo las claves definidas en manifest.

En segundo lugar, asocio las hojas de mosaico y las capas de mosaico con el mapa de mosaico. Ahora puede representar el mapa.

En tercer lugar, iterar a través de mis capas y encontrar todos los objetos especiales que quiero extrusión del mapa: Creatures, Items, Interactablesy así sucesivamente. Creo y almaceno estos objetos para su uso posterior.

Actualmente todavía tengo una API relativamente simple que me permite encontrar, eliminar y actualizar estas entidades:

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

Es esta funcionalidad la que quiero verificar. Si no agrego las capas de mosaico o los conjuntos de mosaico, el mapa no se representará, pero podría probarlo. Sin embargo, incluso llamar a super (...) invoca una lógica específica de contexto o de estado que no puedo aislar en mis pruebas.


2
Estoy confundido. ¿Está tratando de probar que Phaser está haciendo su trabajo al cargar el mapa de mosaico o está tratando de probar el contenido del mapa de mosaico en sí? Si es lo primero, generalmente no prueba que sus dependencias hagan su trabajo; ese es el trabajo del encargado de la biblioteca. Si es esto último, la lógica de tu juego está demasiado unida al marco. Tanto como lo permita el rendimiento, desea mantener puro el funcionamiento interno de su juego y dejar los efectos secundarios en las capas más altas del programa para evitar este tipo de desorden.
Doval

No, estoy probando mi propia funcionalidad. Lamento que las pruebas no se vean así, pero hay algo que se esconde. Esencialmente, estoy mirando a través del mapa de mosaicos y descubriendo mosaicos especiales que convierto en entidades de juego como Objetos, Criaturas, etc. Esta lógica es toda mía y ciertamente debe ser probada.
IAE

1
¿Puedes explicar cómo exactamente Phaser está involucrado en esto entonces? No me queda claro dónde se invoca a Phaser y por qué. ¿De dónde viene el mapa?
Doval

¡Siento la confusión! He agregado mi código Tilemap como ejemplo de una unidad de funcionalidad que estoy tratando de probar. Tilemap es una extensión (u opcionalmente tiene-a) Phaser. Tilemap que me permite renderizar el mapa de mosaico con un montón de funcionalidades adicionales que me gustaría usar. El último párrafo destaca por qué no puedo probarlo de forma aislada. Incluso como componente, en el momento en que new Tilemap(...)Phaser comienza a cavar en su caché. Tendría que diferir eso, pero eso significa que mi Tilemap está en dos estados, uno que no se puede representar correctamente y el completamente construido.
IAE

Me parece que, como dije en mi primer comentario, la lógica de tu juego está demasiado acoplada al marco. Deberías poder ejecutar la lógica de tu juego sin incluir el marco. Acoplando el mapa de mosaico a los activos utilizados para dibujarlo en la pantalla se está interponiendo en el camino.
Doval

Respuestas:


2

Sin conocer Phaser o Typeccipt, todavía trato de darte una respuesta, porque los problemas que enfrentas son problemas que también son visibles con muchos otros marcos. El problema es que los componentes están estrechamente acoplados (todo apunta al objeto de Dios, y el objeto de Dios posee todo ...). Esto es algo que era poco probable que ocurriera si los creadores del framework crearan pruebas unitarias ellos mismos.

Básicamente tienes cuatro opciones:

  1. Detener las pruebas unitarias.
    Estas opciones no deben elegirse, a menos que todas las demás opciones fallen.
  2. Elija otro marco o escriba el suyo.
    Elegir otro marco de trabajo que esté utilizando pruebas unitarias y pierda el acoplamiento hará que la vida sea mucho más fácil. Pero tal vez no haya ninguno que le guste y, por lo tanto, esté atrapado en el marco que tiene ahora. Escribir el tuyo puede llevar mucho tiempo.
  3. Contribuya al marco y haga que sea amigable.
    Probablemente sea lo más fácil de hacer, pero realmente depende de cuánto tiempo tenga y qué tan dispuestos estén los creadores del marco a aceptar solicitudes de extracción.
  4. Envuelva el marco.
    Esta opción es probablemente la mejor opción para comenzar con las pruebas unitarias. Envuelva ciertos objetos que realmente necesita en las pruebas unitarias y cree objetos falsos para el resto.

2

Al igual que David, no estoy familiarizado con Phaser o Typecript, pero reconozco que sus preocupaciones son comunes a las pruebas unitarias con marcos y bibliotecas.

La respuesta corta es sí, shimming es la forma correcta y común de manejar esto con las pruebas unitarias . Creo que la desconexión es entender la diferencia entre las pruebas de unidades aisladas y las pruebas funcionales.

Las pruebas unitarias prueban que pequeñas secciones de su código producen resultados correctos. El objetivo de una prueba unitaria no incluye probar el código de terceros. La suposición es que el código ya ha sido probado para que funcione como lo espera el tercero. Al escribir una prueba unitaria para el código que se basa en un marco, es común calzar ciertas dependencias para preparar lo que parece un estado particular para el código, o calzar el marco / biblioteca por completo. Un ejemplo simple es la administración de sesiones para un sitio web: tal vez la laminilla siempre devuelve un estado válido y consistente en lugar de leer desde el almacenamiento. Otro ejemplo común es la eliminación de datos en la memoria y eludir cualquier biblioteca que consulte una base de datos, porque el objetivo no es probar la base de datos o la biblioteca que está utilizando para conectarse a ella, solo que su código procesa los datos correctamente.

Pero una buena prueba de unidad no significa que el usuario final verá exactamente lo que espera. Las pruebas funcionales toman más de una vista de alto nivel de que una característica completa está funcionando, marcos y todo Volviendo al ejemplo de un sitio web simple, una prueba funcional podría hacer una solicitud web a su código y verificar la respuesta para obtener resultados válidos. Se extiende por todo el código que se requiere para producir resultados. La prueba es para la funcionalidad más que para la corrección del código específico.

Así que creo que estás en el camino correcto con las pruebas unitarias. Para agregar pruebas funcionales de todo el sistema, crearía pruebas separadas que invoquen el tiempo de ejecución de Phaser y verifique los resultados.

Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.