Image for post
Image for post
Rui Lopes Rodrigues | Test Specialist Leader | everis Brasil

Testes de unidade e a evolução “natural”

Testes unitários: não dá para pensar em investimento eficiente na qualidade sem passar por eles. Aqui no Brasil eles demoraram bastante a “pegar”; havia um buraco negro entre as pessoas que achavam que o teste unitário é dever do desenvolvedor e as pessoas que achavam que testes eram tarefa de QA. Neste buraco negro, ficavam os testes unitários.

Felizmente a abordagem ágil hoje tornou-se regra, e não mais exceção. Quando passamos a tratar o sucesso e a qualidade como méritos e responsabilidades de todos, estes silos começaram a se reduzir e passamos a ter menos buracos negros deste tipo.

Ainda há um certo preconceito em relação aos testes por parte dos desenvolvedores e uma certa resistência em algumas pessoas de QA em abraçar atividades mais técnicas, mas o abismo está cada vez menor.

Saindo de cima do muro, em minha não tão humilde opinião, dada a natureza de caixa branca dos testes unitários, estes são atividades de desenvolvimento. Não importa, entretanto, se este desenvolvimento é desempenhado por especialistas em desenvolvimento desenvolvendo ou por especialistas em testes desenvolvendo.

Voltando ao núcleo da nossa conversa: os testes unitários são muito rápidos. Em 99% das situações em que alguém reclama que os testes unitários estão demorando, isto se deve ao fato de que os testes unitários deste time não são de fato unitários. Os testes unitários têm foco no código: se o teste unitário acessa um banco de dados ou um filesystem, já temos uma integração acontecendo. Portanto, este teste já é de integração. Para resolver estes impasses é que existem os mocks, stubs e seus parentes e padrões de projeto como a injeção de dependências. Não vamos baixar no detalhe destes padrões e estruturas neste artigo porque não é seu foco, mas deixo aqui a dica para que quem não têm familiaridade com eles pesquise estes termos e entenda a importância destes na testabilidade de sistemas. Em todos os níveis de testes, mas especialmente no unitário.

Esta velocidade dos testes unitários e dos seus vizinhos do andar de cima da pirâmide de testes, os testes de APIs e micro serviços, são os principais habilitadores de feedback rápido em esteiras de continuous testing.

Falando de pirâmide de testes, este conceito foi criado por Mike Cohn em seu livro “Succeeding with agile”, de 2009, e foi bastante divulgado por Martin Fowler em diversos artigos. Este conceito organiza os testes conforme o modelo abaixo:

Os testes de unidade estão na base desta pirâmide, porque são os mais rápidos e mais baratos. Dos testes automatizados, os testes de UI (User Interface) são os mais lentos e mais caros.

Quando temos uma dependência forte de testes baseados em interface de usuário em nossas pipelines, é muito comum que a quantidade de validações seja limitada pelo tempo de execução, que é um limite difícil de contornar.

Os testes em níveis mais alto de abstração também são importantes para validar o comportamento do sistema. Sim, isto é 100% verdadeiro. Entretanto, o custo de criação e execução de testes em níveis mais altos é muito maior do que aquele dos testes unitários. Na execução, falando de média, no mesmo tempo em que exercitamos um teste de UI, é comum que sejam executados uma centena de testes de serviços e dezenas de milhares de testes unitários. Por isto, com o objetivo do feedback rápido, tudo que pode ser validado no nível unitário deve ser feito aí. Por outro lado, é importantíssimo estar atento aos fluxos que somente podem ser validados em níveis mais altos de abstração. Não devemos achar que todos os problemas são pregos porque temos um ótimo martelo, não é?

Outra questão bastante desafiadora nos testes de unidade é medir a sua efetividade. É comum termos software desenvolvido e coberto de maneira respeitável por testes unitários (chegando a mais de 90% de cobertura de linhas de código por testes), que são também exercitados por testes em níveis de abstração mais altos (testes de API, de micro serviços ou serviços, de UI) que, ao chegarem à produção, geram um volume bastante grande de problemas. Neste caso específico que descrevo, problemas de natureza mais técnica do que funcional.

Há sistemas de muitas características diferentes, mas via de regra quando temos um volume grande de falhas no âmbito mais técnico (falhas originadas em latência de rede, falta de espaço em disco, timeouts no acesso ao banco de dados, concorrências de acesso que não são previstas e assim por diante) este é um forte indicador de falta ou baixa efetividade de testes unitários.

Como assim, baixa efetividade? Uma coisa é gerar os testes unitários para validar o comportamento de seu código, outra coisa bastante diferente é gerar testes unitários para cumprir uma medida de cobertura. Sendo bastante concreto: posso fazer o teste unitário de uma linha de código que escreve dados em um canal qualquer sem me preocupar com o que vai ocorrer quando por alguma razão a escrita neste canal falhar. Normalmente os ambientes de testes são controlados, portanto o teste unitário que passa por esta linha tem uma chance muito grande de jamais ter a falha potencial percebida. Outro exemplo clássico é o de qualquer cálculo matemático que envolva uma divisão. Posso fazer milhares de cálculos sem que ocorra uma divisão por zero, até o dia em que ocorrerá. Normalmente, em produção, naquele momento em que o diretor da empresa demonstrará uma nova funcionalidade para o comitê de avaliação de um possível cliente importante.

Em ambos os casos, o local correto para prevenir os problemas seria o teste unitário. Em empresas com uma política forte de acompanhamento de testes unitários, é muito provável que estas linhas de código onde são chamados os comandos que geram os problemas estejam cobertas por testes, mas que não localizaram estes problemas. Este tipo de problema normalmente não é pego também em testes de níveis mais altos, porque o foco destes testes, conforme vamos galgando o nível de abstração, vai se tornando cada vez mais funcional e menos técnico. Cada vez mais focamos em “o que” o sistema faz, e não em “como” o sistema faz.

Para evitar que este tipo de problema nos encontre desprevenidos, há técnicas de injeção de falhas de forma controlada e acompanhamento de estabilidade de ambientes (chaos engineering e site reliability engineering são temas interessantíssimos), mas o nosso foco neste artigo está no máximo de antecipação dos problemas. Quando uma falha é localizada por um trabalho de chaos engineering, o seu custo já é bem maior do que se fosse resolvido por um teste unitário.

Será que não é possível ter medidas além da cobertura de linhas de código para avaliar a qualidade que está sendo gerada em nossos testes unitários? A efetividade do teste não é mensurável? Essa realmente não é uma medida fácil.

Uma maneira de obter um salto de qualidade no código e nos testes unitários é validá-los com outro par de olhos. A validação por pares é uma das maneiras de obter uma grande evolução na qualidade do código, mas tem um custo também alto.

Outra maneira de validar a qualidade do código é aplicar ferramentas de análise estática do código. Estas ferramentas localizam padrões de falhas e conseguem também uma melhoria bastante significativa no código resultante. Há opções bastante efetivas de ferramentas de código aberto para esta finalidade, trazendo ótimos resultados.

Além destas duas opções, há uma terceira que é mais recente e que oferece, com um investimento pouco maior que da segunda e muito menor que da primeira, resultados muito interessantes. O melhor é que estas alternativas não são excludentes entre elas; podemos aplicar as três, se avaliarmos necessário.

Para dar contexto: uma das teorias científicas mais amplamente aceitas na história moderna é a da evolução, de Charles Darwin. Esta teoria trata dos mecanismos pelos quais as espécies evoluem; falando de maneira simplista, as evoluções ocorrem pela transmissão de características de maior sucesso de geração para geração (porque os indivíduos mais bem sucedidos se reproduzem com maior sucesso) e pela adição de novas características, que são inseridas por mutações aleatórias, oriundas de falhas na replicação de genes.

Voltando ao nosso universo de testes unitários: vamos imaginar que cada execução de um teste unitário possa ocorrer com um indivíduo da espécie representada pela nossa funcionalidade em teste. Imaginemos, agora, que possam ocorrer mutações em nossos indivíduos. Que mutações poderiam ser estas? Por exemplo, poderíamos trocar um operador de comparação maior “>” por menor “<”, um operador de igualdade “==” por um de diferença “!=”. Um retorno de método com valor válido pode passar a ter um retorno nulo… Podemos imaginar mais uma série de mudanças que, colocadas aleatoriamente, fariam o papel das mutações biológicas nas nossas execuções.

Pensando de maneira concreta, o que estas mutações podem nos trazer de positivo? No mundo biológico, quando uma mutação gera um indivíduo menos eficiente este morrerá ou terá dificuldades para se reproduzir. Quando a mutação gera um indivíduo mais eficiente, ela se replicará nas próximas gerações.

Em nossas execuções de testes, se um teste aplicado a uma classe que sofreu uma mutação falhar, não temos grandes novidades. Se o programa sofreu uma mutação e falhou, ocorreu o que era esperado. Mas… E se o teste que foi aplicado a um programa que sofreu uma mutação passar? No mínimo devemos entender que mutação foi esta e porque o teste passou com ela. Será que a mutação está nos mostrando uma condição que não levamos em conta em nosso teste original?

Isto que acabamos de descrever é exatamente o que fazem os frameworks de testes de mutação. Após a execução normal dos seus testes unitários, estes frameworks injetam mutações aleatórias em seu código e o executam novamente com estas mutações. Se o teste falhar esta mutação é descartada, mas se ele passar serão registrados quais foram as mudanças e qual foi o resultado da execução, para análise posterior. Desta forma, utilizando a força bruta do poder de processamento podemos conduzir uma evolução “natural” do nosso conjunto de testes unitários.

Esta é uma maneira bastante interessante de elevar a efetividade do nosso conjunto de testes utilizando um recurso que é cada vez mais barato, que é o poder de processamento. Certamente um investimento em validação por pares terá, pensando no mesmo período investido, resultados mais positivos. Pensando no custo das duas soluções, entretanto, as mutações tendem a gerar resultados mais baratos.

Há alguns frameworks deste tipo. Um dos que é mais comentado é o PIT, para Java; ele é de código aberto e tem uma comunidade bastante ativa, além de se integrar com vários ambientes de execução. Outra ferramenta que também é bastante reconhecida é o Stryker, para JavaScript, C# e Scala.

O objetivo deste artigo era trazer a atenção de vocês para a importância dos testes de unidade e sua eficiência. Aproveitamos o ensejo para apresentar a muitos de vocês os testes de mutação, ferramenta de alto potencial de evolução na qualidade, mas ainda pouco conhecida.

Não deixem de dar atenção, entretanto, às outras técnicas que comentamos no decorrer do artigo: a validação por pares e a análise estática de código são ferramentas de altíssimo potencial de adição de qualidade em nosso código, seja de funcionalidade ou de testes unitários.

Qual é a combinação que melhor atende às necessidades do seu projeto?

Written by

Exponential intelligence for exponential companies

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store