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:
- Você faz um teste
- O teste passa!
- Você se sente um deus da programação
- Dai você "muta" o seu código
- E o bicho continua passando
- 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.
- Isso é curioso pois, mesmo sendo recomendado pela própria
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: 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:
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.
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.
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 valornulo
, pensa que o retorno de uma chamada por ter sido substituída e osassertations
do seu teste verificam isso, assim, o teste falha;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 nenhumverify
desse método o teste não falhou e assim o "mutante sobreviveu";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;NON_VIABLE
: São casos raros mas caso eles aconteçam, significa quê obyte code
do código mutado foi tão alterado que nem se quer conseguiu ser executado;MEMORY_ERRO
: Acontece quando obyte 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;NOT_STARTED
,STARTED
,RUN_ERROR
: Não consegui encontrar nada na documentação! Vou ficar devendo 😶🌫️;NO_COVERAGE
: Parecido com oSURVIVED
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:
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
:
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:
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:
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
- https://www.infoq.com/presentations/mutation-testing/
- https://pitest.org/
- https://gradle-pitest-plugin.solidsoft.info/
Obrigado por ler este artigo! 😄.