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.

IAE
fonte
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.
David Perfors
fonte
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.

Matt S
fonte