Teste de Mutação

June 18, 2023

Header

Introdução

Mesmo que mensuremos os nossos testes com uma boa cobertura de testes, com testes de integração, unitários e end to end, ainda assim somos capazes de entregar bugs (incrível não? hahaha). Então por qual motivo isso acontece? Bem, não sei se consigo listar todos os motivos mas um deles é, quem testa os meus testes? Para falar disso, antes de tudo eu tenho uma coisa a dizer: Os mutantes vão salvar o mundo! E ao fim dessa apresentação eu vou explicar o porquê!

Bem, vocês sabiam que gatos brancos com olhos azuis são em sua maioria surdos? Curioso não?

Você já pensou na multação que você pode ter? Vou listar algumas:

  • Olhos azuis!
    • Apesar de ter sido passado genéticamente de pai para filho, os olhos azuis foi uma mutação genética pois a princípio todos tínhamos os olhos castanhos.
  • Tolerância a lactose
    • Nós não fomos feitos para digerir leite proveniente de outras espécies e essa capacidade na verdade é uma mutação.
  • Não ter dente siso! Alguém ai tem? Se não tem é mutação.
  • Agenesia Dentária
    • Causada por uma anomalia genética, que por sua vez é causada por uma mutação genética, faz com que um dente permanente não se forme e assim a pessoa fica com um ou mais dentes sendo eternamente de leite.
    • Eu tenho! Haha.

Tá Marcelo, mas o que isso tem a ver com o assunto de teeeeestes...???

Por que usamos TDD?

Bem, temos uma série de motivos para usa-los mas abaixo eu listo alguns pontos importantes, são eles:

  • Reduzimos Bugs;
  • Aumenta a velocidade de desenvolvimento (já parou para pensar nisso? haha. É real, eim?! Pratique e verás.);
  • A implementação sempre vai bater com os requerimentos;
  • Menos estresse ao longo do tempo.

Você pode seguir incrementalmente, realizando a entrega de novas features de modo que temos mais garantias de que não vamos entregar bugs, não vamos quebrar o que já está em uso em produção. Com o TDD nós conseguimos realizar refactores mais arriscados, mudar uma arquitetura, mudar a estrutura do fluxo de dados dentro do nosso software, pois o teste consiste basicamente em testar um determinado comportamento da aplicação que é utilizado pelo usuário final! Assim, mesmo com o core do seu software sendo mudado, o comportamento dele deve continuar sendo garantido e o usuário final nem vai perceber que seu software mudou.

Mas ai que eu te pergunto meu caro, posso confiar nos seus testes?

Quem garante que o teste que você escreveu e os asserts que você adicionou a estes conseguem cobrir a alteração de um código que geraria um bug de modo a quebrá-los?

É aqui que entra os testes de mutação! Com eles, somos capazes de validar exatamente o ponto levantado acima. Com esse tipo de testes, nós testamos os nossos testes.

Tá, então qual o ganho eu teria com esse tipo de teste?

Se sua falha custa caro, talvez valha a pena adicionar teste de mutação em sua app, assim cada bug a menos conta! Seus testes devem ser assertivos e evitar entrega de bugs, com isso os testes de mutação podem ajudar.

Ah, mas vale falar também que não se resume "só" essa definição listada acima a motivação para adicionar testes de mutação na sua aplicação. Na verdade pensa ai, seria bom manter um projeto que lhe da a garantia de quê, qualquer alteração feita deve quebrar os testes e, caso os testes não venham a quebrar, temos os testes de mutação que devem lhe ajudar a observar e orientar de como você deve escrever seus testes e quais fluxos devem ser exercitados.

Basicamente, o teste de mutação é baseado em duas premissas

  • Hipóteses de programadores "desenrolados":
    • Às vezes com uma dada experiência você começa a perceber que determinados testes não tem tanto ganho e outros na verdade tem um bom ganho e, com esse feeling, você consegue passa a escrever testes melhores;
    • Ocasionalmente você percebe que um teste não passa por um determinado fluxo que pode acabar impactando muito a aplicação testada.
  • Efeito do acoplamento de código:
    • Isso é interessante! Se você é capaz de testar bem o seu código, isso quer dizer que provavelmente você está codando bem! Caso contrário talvez você possa melhorar algo aí eim meu parceiro?!

Teste de mutação

Primeiro, o teste de mutação executa o seu teste para garantir que ele está funcionando antes de criar os mutantes. Dai ele pega o código que compilou e vai começar a realizar mudanças neles, tais quais:

Temos mudanças de sinal de comparação:

if (suaIdade >= 18) você é maior de idade

O teste de mutação vai mudar isso para if (suaIdade < 18), if (suaIdade > 18).

Mudar retornos que são true para false e vice versa, mudar == para != por exemplo.

Um método pode ser mudado para chamar outro ou ele é removido de modo que nem seja chamado.

Dai tu percebe que basicamente são mudanças quase que infinitas no que diz respeito à possibilidades.

Mas qual por que se fazem essas mudanças?

Basicamente, se seu teste continua passando mesmo com o código mutado, o seu teste tá mal escrito my friend 😐! É isto... (hahaha).

Então my friend, quer dizer que acontece o seguinte:

  1. Você faz um teste
  2. O teste passa!
  3. Você se sente um deus da programação
  4. Dai você "muta" o seu código
  5. E o bicho continua passando
  6. Ai tu pensa, marrapaiz... deu ruim eim?!

Então quer dizer que todo o seu emaranhado de testes não está funcionando bem!? Talvez... Vamos revisar então...

Para aplicar testes de mutação nós usamos o PITest

Requerimentos:

  • Os testes devem ter os mesmos resultados sempre;
  • Podem ser executados em qualquer ordem;
  • Podem ser executados em paralelo;
  • Basicamente, só testes unitários devem funcionar.
    • Isso é curioso pois, mesmo sendo recomendado pela própria framework usar apenas em testes unitários, veremos abaixo que podemos executar testes com uma framework tal qual Spring Boot.

Quais testes devo excluir?

  • Testes end to end;
  • Testes de integrações;
  • Testes de contrato;
  • Qualquer coisa que mude o estado global da aplicação.
    • Uma cache em memória da aplicação se for multado pode acabar impactando outros testes, por exemplo.

E ai é importante dizer: NÃO RODE O PITEST (TESTE DE MUTAÇÃO) EM UM CÓDIGO QUE PODE CAUSAR ALGUM IMPACTO REAL!

Pensa ai em um código tal qual:

if(!safetyFlag) {
	insertTaxsForAllCustomersAccounts();
}

Pensa ai em uma mutação nesse safetyFlag de true para false 👀.

Pondo a mão na massa

Eu criei um projeto para gerir tasks diárias por usuário. Segue a lista de funcionalidades:

  • Cadastrar-se como usuário;
  • Autenticação com JWT;
  • Cadastro de task para um usuário;
  • Listar tarefas para um determinado dia;
  • Marcar task como finalizada.

É um projeto simples mas eu queria fazer duas coisas, aprender gerir a autenticação de usuário com JWT no Spring e criar um projeto de gestão de tasks para mim, para usar no trabalho de modo que fosse totalmente local/offiline. Iria colocar no Github para realizar o download e assim qualquer pessoa também poderia usar.

No fim, eu não consegui o objetivo pois desenvolvi apenas a parte backend do projeto mas aprendi a gerar a autenticação de um usuário com JWT, melhorei meus conhecimentos do uso do DynamoDB em ambiente local, e o melhor de tudo, aprendi e apliquei conhecimentos a respeito do uso de testes de mutação. Veremos abaixo como eu os apliquei a esse projeto.

Aplicando testes de mutação em uma simples API

O projeto que foi aplicado todos esses testes está nesse repositório do Github: task-manager-spring-boot-jwt. Fique a vontade para abri-lo e usa-lo como quiser.

Configuração

Basicamente, é necessário adicionar um plugin ao seu build.gradle e depois aplica-lo como mostro no código abaixo:

plugins {
   id 'info.solidsoft.pitest' version '1.9.11'  
}  
  
apply plugin: 'info.solidsoft.pitest'

Lembre-se também de importar um plugin para que o PITest consiga "ler" o JUnit aplicado aos testes do seu projeto, veja:

implementation 'org.pitest:pitest-junit5-plugin:1.2.0'

Agora para finalizar, configure a integração desse plugin do JUnit ao PITest em sua task gradle. Segue abaixo:

pitest {  
   junit5PluginVersion = '1.2.0'  
   targetClasses = ["br.com.marcelo.azevedo.*"]  
}

Todas essas configurações foram obtidas seguindo essa documentação.

Aplicando ao projeto

Nesse projeto temos uma estrutura de pacotes simples, junto as classes pelos títulos e as coloco em pacotes que fazem referências a essas classes. Você pode ver mais detalhes desse projeto baixando o código fonte. O que acredito que valha falar é sobre os níveis software utilizados aqui e como os dados vão "descendo" entre esses níveis. Essa forma de estruturar cada uma das classes e suas responsabilidades, isso ajudou o projeto a ficar mais fácil de testar.

Abaixo um print com essas camadas de dados em evidência: Camadas de Dados Falarei mais dessas classes abaixo.

Controller

Como é de praxe em um projeto Spring Boot (Ou qualquer projeto de API?), alguma classe tem que ficar responsável por receber e responder as requisições feitas a aplicação. Aqui, as classes de Controllers se comunicam com as de Facades, que é a próxima camada de software que temos após os Controllers. Sua responsabilidade é unicamente essa, receber a requisição, repassar para a Facade que tem a capacidade de processar o fluxo necessário, receber o retorno da Facade e repassar.

Facade

Na Facade, nós temos um cara responsável por "orquestrar" as lógicas que são necessárias para que algum fluxo seja executado com sucesso. Então nessa aplicação, o padrão Facade é responsável por aninhar e ordenar as chamadas que são feitas aos Services, de modo que ele em si, só pega os retornos dessas chamadas o os utiliza para realizar uma nova chamada a algum service ou retornar para o Controller.

Service

Aqui é onde temos o tratamento da lógica da aplicação, que para o nosso caso é bem simples (diga-se de passagem). Porém, temos algumas ordenações, chamadas de I/O feitas ao banco, lançamento de exceções para interromper algum fluxo e outras coisas mais. Os Services são a única e exclusiva camada que tem por responsabilidade realizar alguma lógica, algum processamento de dado.

Domínios da aplicação

Nesse projeto nós temos dois principais "domínios", o do usuário e o das tasks. E é importante falar disso pois para esse projeto, temos que garantir que esses dois estejam funcionando bem, por isso que me concentrei em realizar testes mais focados nesses dois fluxos e para outros você vai observar a faltam de testes (Não me julguem 👀).

Abaixo vemos as classes de testes baseadas em componentes e testes unitários: Pacotes e classes de teste

Nos testes que tem o sufixo FlowTest nós usamos a Framework Spring Boot para realiza-los. Mas nos testes da camada de Service nós usamos apenas o Mockito mesmo.

Geralmente os testes de mutação, como falei acima, devem ser aplicados apenas a testes unitários. Porém, como era um projeto simples eu fui além e resolvi aplicar a todos os testes que o projeto tem. Observei que mesmo com os testes que utilizam a própria biblioteca de testes do Spring eu não tive problemas! Talvez isso tenha acontecido pela simplicidade do projeto ou talvez a biblioteca de testes de mutação tenha evoluído o suficiente de modo que hoje seja possível de aplicar até a testes que usam a biblioteca do próprio Spring para testes.

PITest

O PITest é uma framework que ajuda a aplicar o teste de mutação. Como a própria documentação informa, o PITest adiciona "mutantes" ao byte code gerado no build da aplicação. Ele vai modificar conforme falamos acima no passo [[#Teste de mutação]] e rodar os testes da sua aplicação, caso todos os mutantes morram, seus testes estão bem escritos e, caso não, eles podem melhorar.

Abaixo temos uma saída do console que já possui algumas informações a respeito do tipo de mutação e o status do mutante.

Output inicial

Acredito que a para os tipos de mutação as classes são auto descritivas, como por exemplo o NullReturnValsMutator, que substitui o valor de uma variável por null. Dessa saída mostrada acima é importante valarmos do status do mutante, vamos dar uma olhada mais detalhada em cada um deles.

  1. KILLED: Nesse caso o mutante foi morto, ou seja, o código mutante obteve erro ao tentar se executado! Quer dizer que seu código de teste está bem escrito. No caso do exemplo que citei da substituição por um valor nulo, pensa que o retorno de uma chamada por ter sido substituída e os assertations do seu teste verificam isso, assim, o teste falha;
  2. SURVIVED: Muitos testes na saída acima acabaram entrando nesse status. Quando que ele acontece? Por exemplo, o código teve uma mutação de modo que houve a remoção da chamada de um método, como não havia nenhum verify desse método o teste não falhou e assim o "mutante sobreviveu";
  3. TIME_OUT: Acontece quando o código muda de modo a alterar, por exemplo, a condição de parada de um loop, fazendo seu código executar eternamente;
  4. NON_VIABLE: São casos raros mas caso eles aconteçam, significa quê o byte code do código mutado foi tão alterado que nem se quer conseguiu ser executado;
  5. MEMORY_ERRO: Acontece quando o byte code mutado da sua aplicação foi tão alterado que gerou um estouro de memória, por exemplo. Se isso acontecer, considere disponibilizar mais espaço de memória nas configurações da sua JVM;
  6. NOT_STARTED, STARTED, RUN_ERROR: Não consegui encontrar nada na documentação! Vou ficar devendo 😶‍🌫️;
  7. NO_COVERAGE: Parecido com o SURVIVED só que aqui o problema é que nenhum teste cobriu o código mutado! Nesse caso, vale escrever mais testes 👀.

Depois de entendermos os status de cada uma das mutações do PITest, vamos dar uma olhada no relatório mais detalhado que é gerado e exibido através de uma página html. Veja: Relatório inicial do PITest

Nesse relatório, nós conseguimos observar onde falharam os mutantes e o motivo pelo qual eles falharam. Lembra que eu comentei que me concentrei nos testes relacionados a gestão de usuários e tasks? Então, veja que na parte de Controllers e Services nós temos uma boa cobertura e a força do teste é ao menos boa!

De cara, nesse relatório você consegue observar quais pacotes faltam testes, mais especificamente olhando por Line Coverage. Olhando o Mutation Coverage nós temos a informação de onde devemos melhorar os nossos testes.

Nos nossos testes você vai observar que temos apenas o UserFlowTest, que é o TDD da gestão de usuário da aplicação, falta o que seria o TaskFlowTest. Por isso que olhando o pacote de Controllers no relatório listado acima, vemos tanto o Line Coverage quando o Mutation Coverage é baixo, pois só temos cobertura para o fluxo de gestão de usuário.

Abaixo, veja o que o relatório diz especificamente do TaskController: Relatório especificamente da classe TaskController

Como nenhum dos endpoints não estão sendo cobertos, nenhum mutante foi morto pelos nossos testes.

Já no pacote dos Services nós temos uma boa cobertura, veja: Relatório de cobertura do pacote de Services

Foram poucos os mutantes que sobreviveram e o código está bem coberto por testes. Isso quer dizer que os testes que temos cobrem bem o código dessa camada de software e está bem escrito.

Por exemplo, da classe TaskService os mutantes que sobreviveram foi única e exclusivamente pelo fato de não termos teste de alguns de seus métodos. Talvez com o TaskFlowTest nós já conseguíssemos cobrir esses métodos. Veja: Estrutura do Projeto Veja que nas linhas em vermelho da sessão "Mutations" o erro aconteceu pela ausência de cobertura de testes ao código da aplicação que foi mutado.

Conclusão

Apesar da própria framework PITest recomendar o seu uso apenas em testes unitários, no nosso projeto aplicamos a ferramenta a testes de componentes e não tivemos nenhum problema, como é o caso do UserFlowTest! Talvez a depender da complexidade do projeto e dependências como mocks em chamadas de APIs, publicação e leitura de eventos e outras coisas mais..., acabemos por ter problemas ao usar o PITest. No meu entender, vale aplicar o PITest e observar como seu projeto se comporta e, a partir daí, tomar alguma ação.

Acredito que após passar por todo o conteúdo acima, os testes de mutação agregam muito valor no que diz respeito a qualidade de nossos testes. Ele consegue de forma clara exibir através de um relatório onde e como podemos melhorar os nossos testes, por isso é uma ferramenta que pode ajudar bastante a melhorar a qualidade das entregas que fazemos.

Em contrapartida, a aplicação do framework PITest pode acabar levando tempo, podemos ter estouro de memória, timeout na execução de um teste (a mutação de uma condição de um while pode fazê-lo executar infinitamente, por exemplo) e outros problemas que podem deixar complexo adicioná-lo a uma pipeline de entrega, por isso tenha cautela de onde e como usar tal prática e assim colher o máximo do seu ganho evitando seus problemas de forma inteligente.

Referências

Obrigado por ler este artigo! 😄.


Profile picture

Escrito por Marcelo Azevedo que mora no interior da paraíba tentando aprender coisinhas legais para ensinar a quem quiser ler. E ai, bora me seguir no LinkedIn?

© 2024, por mim mesmo 😝.