Unidade de teste de uma estrutura estável, como a Phaser?


9

TL; DR Preciso de ajuda na identificação de técnicas para simplificar o teste de unidade automatizado ao trabalhar em uma estrutura estável.


Fundo:

Atualmente, estou escrevendo um jogo no TypeScript e na estrutura da Phaser . A Phaser se descreve como uma estrutura de jogo HTML5 que tenta o mínimo possível para restringir a estrutura do seu código. Isso vem com algumas trocas, a saber, que existe um objeto de Deus Phaser.Game que permite acessar tudo: cache, física, estados do jogo e muito mais.

Esse estado torna muito difícil testar muitas funcionalidades, como o meu Tilemap. Vamos ver um exemplo:

Aqui estou testando se minhas camadas de mosaico estão ou não corretamente e posso identificar as paredes e criaturas no meu 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]
            ]);

Não importa o que eu faça, assim que tento criar o mapa, a Phaser chama internamente seu cache, que é preenchido apenas durante o tempo de execução.

Não posso invocar esse teste sem carregar o jogo inteiro.

Uma solução complexa pode ser escrever um adaptador ou proxy que apenas construa o mapa quando precisarmos exibi-lo na tela. Ou eu mesmo poderia preencher o jogo carregando manualmente apenas os ativos necessários e, em seguida, usando-o apenas para a classe ou módulo de teste específico.

Eu escolhi o que considero uma solução mais pragmática, mas estranha para isso. Entre o carregamento do meu jogo e a execução real, eu calcei um TestStateque executa o teste com todos os ativos e dados em cache já carregados.

Isso é legal, porque eu posso testar todas as funcionalidades que eu quero, mas também não é legal, porque esse é um teste técnico de integração e nos perguntamos se eu não poderia apenas olhar para a tela e ver se os inimigos são exibidos. Na verdade, não, eles podem ter sido identificados erroneamente como um Item (já aconteceu uma vez) ou - posteriormente nos testes - eles podem não ter recebido eventos relacionados à sua morte.

A minha pergunta - é comum o shimming em um estado de teste como este? Existem abordagens melhores, especialmente no ambiente JavaScript, das quais não conheço?


Outro exemplo:

Ok, aqui está um exemplo mais concreto para ajudar a explicar o que está acontecendo:

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

Eu construo meu Tilemap a partir de três partes:

  • Os mapas key
  • O manifestdetalhamento de todos os ativos (planilhas e sprites) exigidos pelo mapa
  • A mapDefinitionque descreve a estrutura e as camadas do tilemap.

Primeiro, devo chamar super para construir o Tilemap na Phaser. Essa é a parte que chama todas essas chamadas para o cache, enquanto tenta procurar os ativos reais e não apenas as chaves definidas no manifest.

Segundo, associo as folhas de mosaico e as camadas de mosaico ao Tilemap. Agora ele pode renderizar o mapa.

Terceiro, eu iterate através das minhas camadas e encontrar objetos especiais que eu quero extrusão a partir do mapa: Creatures, Items, Interactablese assim por diante. Eu crio e armazeno esses objetos para uso posterior.

Atualmente, ainda tenho uma API relativamente simples que permite encontrar, remover, atualizar essas 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 });
    }

É essa funcionalidade que quero verificar. Se eu não adicionar as camadas ou os conjuntos de blocos, o mapa não será renderizado, mas talvez eu possa testá-lo. No entanto, mesmo chamar super (...) invoca uma lógica de estado ou de estado específico que não consigo isolar em meus testes.


2
Estou confuso. Você está tentando testar se a Phaser está fazendo o trabalho de carregar o mapa de mosaico ou está tentando testar o conteúdo do próprio mapa de mosaico? Se for o primeiro, você geralmente não testa se suas dependências fazem o trabalho delas; esse é o trabalho do mantenedor da biblioteca. Neste último caso, sua lógica de jogo está muito acoplado à estrutura. Tanto quanto o desempenho permitir, você deseja manter o funcionamento interno do seu jogo puro e deixar os efeitos colaterais nas camadas superiores do programa para evitar esse tipo de confusão.
— Doval

Não, estou testando minha própria funcionalidade. Sinto muito se os testes não são assim, mas há um pouco por trás das cobertas. Basicamente, estou examinando o mapa de peças e descobrindo peças especiais que converto em entidades de jogo, como itens, criaturas etc. Essa lógica é toda minha e certamente deve ser testada.
— IAE

11
Você pode explicar exatamente como a Phaser está envolvida nisso? Não está claro para mim onde a Phaser é invocada e por quê. De onde vem o mapa?
— Doval

Sinto muito pela confusão! Eu adicionei meu código do Tilemap como um exemplo de uma unidade de funcionalidade que estou tentando testar. O Tilemap é uma extensão (ou opcionalmente tem um) Phaser.Tilemap que me permite renderizar o tilemap com várias funcionalidades extras que eu gostaria de usar. O último parágrafo destaca por que não posso testá-lo isoladamente. Mesmo como componente, no momento em que eu apenas new Tilemap(...)Phaser começa a cavar em seu cache. Eu teria que adiar isso, mas isso significa que meu Tilemap está em dois estados, um que não pode render-se adequadamente e o totalmente construído.
— IAE

Parece-me que, como eu disse no meu primeiro comentário, sua lógica de jogo está muito acoplada à estrutura. Você deve ser capaz de executar a lógica do jogo sem trazer a estrutura. O acoplamento do mapa do bloco aos ativos usados ​​para desenhá-lo na tela está atrapalhando.
— Doval

Respostas:


2

Não conhecendo Phaser ou Typescipt, ainda tento dar uma resposta, porque os problemas que você está enfrentando são também visíveis em muitas outras estruturas. O problema é que os componentes estão fortemente acoplados (tudo aponta para o objeto de Deus, e o objeto de Deus é dono de tudo ...). É improvável que isso aconteça se os criadores da estrutura criarem os próprios testes de unidade.

Basicamente, você tem quatro opções:

  1. Pare o teste de unidade.
    Esta opção não deve ser escolhida, a menos que todas as outras opções falhem.
  2. Escolha outra estrutura ou escreva sua própria.
    A escolha de outra estrutura que esteja usando o teste de unidade e com perda de acoplamento tornará a vida muito mais fácil. Mas talvez não haja nada que você goste e, portanto, você está preso à estrutura que possui agora. Escrever o seu próprio pode levar muito tempo.
  3. Contribua com a estrutura e faça com que ela seja amigável.
    Provavelmente o mais fácil de fazer, mas realmente depende de quanto tempo você tem e de como os criadores da estrutura estão dispostos a aceitar solicitações pull.
  4. Enrole a estrutura.
    Esta opção é provavelmente a melhor opção para iniciar o teste de unidade. Enrole certos objetos que você realmente precisa nos testes de unidade e crie objetos falsos para o resto.

2

Como David, não estou familiarizado com Phaser ou Typescript, mas reconheço suas preocupações como comuns ao teste de unidade com estruturas e bibliotecas.

A resposta curta é sim, shimming é a maneira correta e comum de lidar com isso com testes de unidade . Eu acho que a desconexão está entendendo a diferença entre testes de unidade isolados e testes funcionais.

O teste de unidade prova que pequenas seções do seu código produzem resultados corretos. O objetivo de um teste de unidade não inclui o teste de código de terceiros. A suposição é que o código já foi testado para funcionar como esperado pela terceira parte. Ao escrever um teste de unidade para código que depende de uma estrutura, é comum calçar certas dependências para preparar o código que parece um estado específico para o código ou calçar a estrutura / biblioteca inteiramente. Um exemplo simples é o gerenciamento de sessões de um site: talvez o shim sempre retorne um estado válido e consistente, em vez de ler do armazenamento. Outro exemplo comum é reduzir os dados na memória e ignorar qualquer biblioteca que consulta um banco de dados, porque o objetivo não é testar o banco de dados ou a biblioteca que você está usando para se conectar a ele, apenas que o seu código processa os dados corretamente.

Mas um bom teste de unidade não significa que o usuário final verá exatamente o que você espera. O teste funcional tem uma visão de alto nível de que todo um recurso está funcionando, estruturas e tudo. De volta ao exemplo de um site simples, um teste funcional pode fazer uma solicitação da Web para o seu código e verificar a resposta para obter resultados válidos. Ele abrange todo o código necessário para produzir resultados. O teste é mais para funcionalidade do que para correção de código específico.

Então, acho que você está no caminho certo com o teste de unidade. Para adicionar testes funcionais de todo o sistema, eu criaria testes separados que invocariam o tempo de execução da Phaser e verificariam os resultados.

Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.