Teste de unidade parte 2 - Teste de unidade MonoBehaviour

TOMEK PASZEK Anonymous
Jun 3, 2014|10 Min
Teste de unidade parte 2 - Teste de unidade MonoBehaviour
Esta página da Web foi automaticamente traduzida para sua conveniência. Não podemos garantir a precisão ou a confiabilidade do conteúdo traduzido. Se tiver dúvidas sobre a precisão do conteúdo traduzido, consulte a versão oficial em inglês da página da Web.

Conforme prometido em minha postagem anterior no blog Testes unitários parte 1 - Testes unitários segundo o livro, esta é dedicada a projetar o MonoBehaviour com a testabilidade em mente. MonoBehaviour é uma espécie de classe especial que é tratada pela Unity de uma maneira especial. Toda vez que você tentar instanciar um derivado do MonoBehaviour, receberá um aviso dizendo que isso não é permitido. Sendo um bom escoteiro e não ignorando o aviso (ignorar um aviso é ruim a longo prazo!), você deve ter se perguntado: como posso zombar do MonoBehaviour? A boa notícia é que você não precisa fazer isso! Deixe-me apresentá-lo a...

O padrão de objeto humilde

Se você já tentou escrever testes, provavelmente se deparou com alguns dos inimigos naturais dos testes unitários, como interface do usuário, código legado, design ruim sem acesso ao código-fonte ou áreas com alto grau de simultaneidade. O que torna essas peças difíceis de testar? Alcançar o isolamento: separar o que está sendo testado do contexto. Existem ferramentas que podem ajudar no código legado, mas para o novo código, um padrão muito simples pode ser usado: O padrão de objeto humilde.

A ideia por trás desse padrão é muito simples. Sempre que você quiser testar um componente que tenha dependências difíceis de testar, extraia toda a lógica do componente para uma classe separada e desacoplada (portanto, testável) e faça referência a ela. Em outras palavras, o componente problemático (com uma dependência que torna a vida dos autores de testes miserável) torna-se uma camada muito fina de código que tem o mínimo de código lógico possível, com todas as operações lógicas delegadas à classe recém-criada.

De um estado em que o teste tem uma dependência indireta do componente não testável...

exemplo-dependência1

...chegamos a um estado em que o teste nem sequer está ciente do código ruim (bem, apenas não testável):

É basicamente isso. Para ser sincero, não é preciso pensar muito.

Jogos versus testabilidade

O que torna os jogos tão especiais em termos de código e capacidade de teste? Como o teste de jogos é diferente do teste de outros softwares? Pessoalmente, considero os jogos como um software bastante sofisticado. Seria ingênuo dizer que os jogos não são muito diferentes do software que você usa todos os dias. Nos jogos (com exceções, é claro), você encontrará gráficos brilhantes e refinados, música de fundo e outras amostras de som bem projetadas. Os jogos geralmente precisam lidar com entradas em tempo real, possivelmente de várias fontes, bem como com uma variedade de dispositivos de saída (resolução de leitura). Os requisitos não funcionais também podem ser mais rigorosos para jogos. Os jogos Multiplayer exigem uma conexão de rede confiável e sincronizada e, ao mesmo tempo, o desempenho necessário para manter uma taxa de quadros constante.

Isso pode resultar em um sistema complexo que abrange muitos tipos diferentes de mídia e tecnologias. Para mim, os jogos sempre foram obras-primas do produto final de software, com alguns deles aspirando a ser reconhecidos como obras de arte (tanto no aspecto clássico e visual quanto no aspecto técnico e nos bastidores).

Unity versus testabilidade

Toda essa complexidade tem consequências para a arquitetura do código. Para nossa infelicidade, as arquiteturas de alto desempenho geralmente funcionam contra um bom design de código, uma restrição que você também pode encontrar no Unity. Um dos mecanismos principais que precisou ser projetado de maneira especial é o mecanismo MonoBehaviour. Se você já se perguntou por que os retornos de chamada no MonoBehaviour não são implementados com interfaces ou herança (como o senso comum talvez sugira), é por motivos de desempenho (veja o esclarecimento de Lucas Meijer nos comentários). Sem entrar em detalhes, isso também prejudica a capacidade de teste do MonoBehaviour. O fato de não ser possível instanciar um MonoBehaviour com o novo operador praticamente o proíbe de usar qualquer estrutura de simulação existente. Provavelmente não seria uma boa ideia, de qualquer forma, com todas as coisas que estão acontecendo nos bastidores toda vez que um MonoBehaviour é usado. Interceptar esse comportamento geraria muitos problemas.

Você x testabilidade

No final, tudo depende de você e de sua motivação para escrever um código testável. Muitas abordagens podem resolver o mesmo problema, mas apenas algumas funcionarão bem para a automação de testes. Se você quiser escrever um código testável, às vezes precisará escrever mais código do que imagina ser necessário. Se ainda estiver aprendendo (não deveríamos aprender a vida toda?) ou se tiver acabado de entrar no caminho da aventura da automação de testes, talvez considere algumas das partes do código ou suposições de projeto como uma sobrecarga desnecessária. No entanto, eles se tornam um hábito tão rapidamente que você nem perceberá quando começar a usar os designs pró-automação sem sequer pensar nisso.

Nesta postagem do blog, prometi mostrar uma maneira de projetar o MonoBehaviour para poder testá-los depois. Isso não era totalmente verdade, porque não testaremos os MonoBehaviours em si. Você provavelmente já tem uma ideia de como implementar o Humble Object Pattern em seu projeto para torná-lo mais testável, mas, mesmo assim, deixe-me mostrar a ideia implementada em um projeto real.

O exemplo

Vamos criar um caso de uso para o propósito deste exemplo. Imagine um controle de jogador simples que seja responsável pela direção de uma nave espacial. Para simplificar o exemplo, vamos colocá-lo em um espaço mundial 2D. Queremos que a nave espacial possa voar em todas as direções. Ele tem uma arma que pode atirar em linha reta com balas (foguetes espaciais?), mas não com mais frequência do que uma determinada taxa de disparo. O número de balas também é limitado pela capacidade do porta-balas, portanto, se você atirar em todas elas, precisará recarregar. Para torná-lo mais interessante, vamos fazer com que a velocidade de movimento dependa da saúde da espaçonave.

Um MonoBehaviour que servirá como controlador para nossa espaçonave poderia ter a seguinte aparência:

Imagem

Na chamada de retorno FixedUpdate, lemos a entrada e executamos a ação, dependendo de quais botões foram pressionados pelo usuário. Para nos movermos ao redor da espaçonave, precisamos traduzir a posição da espaçonave com a velocidade constante de acordo com a direção dos eixos. Como você pode ver no código, as variáveis deltaX e deltaY são multiplicações de: Time.fixedDeltaTime, o valor do eixo de entrada e a constante de velocidade que, por sua vez, depende do nível de integridade.

No evento Fire1 (por exemplo, clique com o botão esquerdo do mouse), queremos verificar se é possível disparar a bala. Em primeiro lugar, precisamos ter pelo menos uma bala no porta-balas. Em segundo lugar, queremos permitir que a espaçonave atire apenas em uma determinada taxa (uma vez a cada meio segundo, neste caso). Portanto, verificamos quanto tempo se passou desde que a última bala foi disparada. Se estivermos prontos para ir, vamos desovar a bala.

O evento Fire2 simplesmente recarregará o suporte de balas.

Para escrever testes unitários para essa lógica, precisamos superar dois problemas. O primeiro, como mencionado anteriormente, é a classe MonoBehaviour não simulável da qual dependemos por meio de herança. O segundo problema é mais geral para o software em tempo real. Nossa lógica depende do tempo (taxa de disparo), o que impossibilita a realização de testes unitários, pois não podemos interceptar a classe estática Time da Unity. A boa notícia é que tudo isso pode ser resolvido.

Vamos refatorar um pouco nosso código aplicando algumas refatorações simples de extração de métodos e tendo em mente que os métodos lógicos não devem fazer referência à API do Unity (manipulação de entrada e instanciação de bala, neste caso). A dependência de tempo na instrução if também deve ser extraída para um método separado. O resultado final deve ser mais ou menos assim:

exemplo2

Como você pode ver, o método FixedUpdate aqui não faz nada mais do que passar a entrada dos usuários para o método que faz a parte lógica. A verificação da taxa de disparo foi extraída para o método CanFire, que gera o resultado "true" se um período de tempo especificado tiver passado. Essa extração é importante, pois nos permitirá escrever testes unitários posteriormente. Se pudéssemos simular a classe SpaceshipMotor neste momento, simplesmente interceptaríamos o método CanFire e faríamos com que ele retornasse verdadeiro ou falso sempre que quiséssemos. Isso tornaria o teste independente do tempo. Mas como não podemos simular o SpaceshipMotor porque ele herda do MonoBehaviour, precisamos aplicar o Humble Object Pattern.

Como podemos fazer isso? Precisamos simplesmente extrair todo o código lógico que não usa a API do Unity para uma classe separada e introduzir uma referência a ela no SpaceshipMotor. Vamos dar uma olhada na classe novamente e ver o que extrair. O TranformPosition e o InstanciateBullet usam a API do Unity, mas todo o resto pode ser extraído. Sei que também existe a classe estática Time, mas voltarei a esse assunto mais tarde.

A última coisa a ser explicada antes de fazermos a extração real é como a lógica extraída se comunica com a API do Unity sem depender dela. É nesse ponto que entram as interfaces. A classe com lógica terá uma referência a uma interface, e não me preocuparei com a implementação real. Para simplificar as coisas, podemos implementar essas interfaces diretamente no próprio MonoBehaviour! Vamos dar uma olhada nas duas classes a seguir:

Exemplo3
Exemplo4

Vamos começar com a classe SpaceshipMotor. A classe implementou algumas interfaces que são responsáveis por transformar a posição da nave espacial e instanciar a bala, respectivamente. A própria classe tem um campo que se refere ao SpaceshipController, que implementa toda a lógica agora. A classe SpaceshipController não sabe nada sobre a SpaceshipMotor e a única coisa que pode fazer é invocar métodos das interfaces às quais faz referência.

A Unity não serializará referências às interfaces. Se você não se importar com a serialização, basta passar as referências de interface ao construir a classe SpaceshipController. Caso contrário, você pode definir as referências na chamada de retorno OnEnable, que é chamada todas as vezes após a serialização. Só para constar, toda a classe SpaceshipMotor será serializada da maneira usual, apenas as referências de interface serão perdidas.

Você deve ter notado a referência à classe Time no SpaceshipMotor. Eu sei que disse que não deveria haver nenhuma referência à API do Unity aqui, mas eu a deixei lá para demonstrar uma abordagem diferente para lidar com dependências dependentes de tempo. Idealmente, poderíamos simplesmente passar o valor Time.time como um argumento para os métodos.

Para os fãs de UML, este é o resultado final como um diagrama UML (simplificado):

exemplo-uml1
Testes unitários

Com a classe SpaceshipMotor desacoplada, não há nada que nos impeça de escrever alguns testes de unidade. Dê uma olhada em um dos testes:

Exemplo5

O teste valida que você não pode disparar se não tiver mais balas. O teste em si é estruturado de acordo com o padrão Arrange-Act-Assert. Na parte de organização, criamos simulações de objetos com os métodos GetGunMock e GetControllerMock. O GetControllerMock, além de criar uma simulação, substitui o comportamento do método CanFire para que ele sempre retorne true. Isso remove a dependência de tempo do objeto controlador. Em seguida, definimos o número do marcador atual como 0. Depois disso, aplicamos o fire à classe do controlador e verificamos se o Fire não foi chamado na interface do controlador da arma.

Há mais alguns testes no projeto. Você pode pegá-lo aqui e brincar um pouco com ele. Usei o NSubstitute para o objeto de simulação. Também enviamos uma versão dele com as Unity Test Tools. Todas as três versões do controlador que discutimos aqui estão anexadas ao projeto.

Hoje é só isso. Espero que você tenha gostado da leitura e bom teste!

Tomek