Arquitetura

Como funciona uma Pirâmide de Testes

Para que possamos identificar a forma mais adequada de testar e garantir a qualidade dos nossos serviços existe a metáfora visual da Pirâmide de Testes.

Testes são complexos e desafiadores. Alguns anos atrás o Agibank tinha um time inteiro composto de profissionais de QA, que recebiam software pronto e tinham a responsabilidade de garantir que o software desenvolvido estava preparado para ir à produção. Os testes eram, na sua grande maioria, manuais.

Esta abordagem tem duas grandes falhas:

  • Testes manuais são demorados. Não mande um humano fazer o trabalho que uma máquina pode fazer muito mais rápido e 24/7.
  • Os testes eram feitos no final do processo de desenvolvimento. O fato do profissional de QA receber o software após o término do desenvolvimento, bem como não participar da construção e levantamento dos cenários de teste, fazia com que tivéssemos retrabalho e dificuldade no entendimento e conclusão do trabalho. E claro, também gerava atritos entre os times.

Adicione uma pitada de complexidade quando, ao escolhermos a abordagem de Microservices, temos responsabilidades espalhadas entre serviços diferentes e por vezes queremos testar um cenário que envolve mais de um serviço.

O Microservice é uma arquitetura de software bastante popular, e é simples de entender o porquê. Existem muitas vantagens em utilizar essa abordagem, como a facilidade de manutenção de código, a escalabilidade, a flexibilidade de tecnologia e a possibilidade de autonomia entre times. Porém precisamos conhecer e saber lidar com as desvantagens que vão aparecer, como por exemplo a maior degradação de performance devido a latência de rede, a complexidade de monitoramento e o tema deste artigo, o aumento no desafio para testar nossos sistemas.

Nos áureos tempos do Monolito, provavelmente poderíamos resolver isso com uma simples chamada de método para outro módulo em outra classe. Isso não significa que Monolitos não possuam dependências externas – sempre teremos aquela dependência de banco de dados ou de um file system – mas, certamente, Microservices possuem muito mais dependências, uma vez que nossos serviços se comunicam com outros para resolverem problemas de domínios diferentes.

E é para que possamos identificar a forma mais adequada de testar e garantir a qualidade dos nossos serviços que existe a metáfora visual da Pirâmide de Testes.

O conceito é simples. Existem diferentes tipos de testes: Unit TestIntegration TestComponent Test e End to End.

Quanto mais ao topo da pirâmide, maior a complexidade, mais lento e mais custoso é o teste a ser escrito. Quanto mais à base da pirâmide, menos complexo, mais rápido e confiável é o teste a ser escrito.

Então podemos concluir que devemos escrever apenas os testes mais rápidos, menos custosos e mais confiáveis, certo? Mas é claro que… não.

Não é assim tão simples. Os Unit Tests são rápidos para executar e de baixa complexidade, exatamente porque são utilizados para testar classes e métodos isolados. Enquanto testes End to End são custosos e não confiáveis porque são responsáveis por testar fluxos inteiros que podem envolver dezenas ou centenas de serviços e dependências externas, além de apresentarem muitos cenários e fluxos que precisam ser mapeados para que possamos cobrir todas as possibilidades do sistema a ser testado.

Ambos precisam ser feitos para que possamos ter garantia e confiança naquilo que estamos entregando, porém sabemos que temos prazos e escopos definidos, então precisamos decidir onde devemos investir mais nosso esforço.

De acordo com Chris Richardson, autor do livro Microservices Patterns, a proporção que devemos investir em nossos testes é refletida na proporção da imagem da Pirâmide de Testes. Quanto mais alto o nível, menos testes devemos ter. Isso não significa que não devemos ter nenhum teste do tipo mais alto da pirâmide, apenas que devemos ter mais testes do tipo da base.

Mas agora vamos analisar cada um dos tipos para entender melhor.

Unit Test

Testes de unidade, ou testes unitários, são responsáveis por validar a menor parte testável de um software. Ou seja, a rigor, um teste de unidade deve testar um método de uma classe. Devem ser rápidos!

Importante observar que o teste de unidade deve testar a interface pública de uma classe. Métodos privados normalmente são considerados um detalhe de implementação e não devem ser testados de forma isolada. Bom, se talvez você se deparar com esta situação, é um sinal que a classe que está sendo testada está mais complexa do que deveria e provavelmente poderia ser refatorada… e isso é ótimo, testes nos ajudam a avaliar com mais cuidado o design do nosso código!

Martin Fowler gosta de dividir os testes de unidade em 2 tipos diferentes, e é importante saber reconhecer quando usar cada deles: Sociable e Solitary.

Imagine que você está querendo testar uma classe que possui um método que calcula o preço de algo. Este método precisa chamar algumas outras funções das classes Produto e Cliente. Se quisermos escrever testes de unidade do tipo Solitary então não queremos utilizar as classes de Produto e Cliente de verdade, porque uma falha nelas poderia fazer com que nosso teste falhe também. Para resolver este problema devemos utilizar o que chamaremos de Test Doubles.

Test Double

Termo genérico para os casos em que queremos substituir objetos e dependências no método a ser testado, alguns exemplos de Test Double são DummyFakeStubsSpies e Mocks. Muitos de nós estamos familiarizados com os Mocks, mas é legal saber que existem mais opções que podem nos ajudar na hora de criar bons testes. Quem sabe a gente se aprofunda nesse tema em outro artigo! 😊

​​​​​​​Mas nem sempre é interessante escrever apenas testes de unidade do tipo Solitary. Existem momentos em que você vai querer testar o comportamento do seu método levando em conta todas as regras de negócio que existam em outras classes que serão chamadas pelo método, desde que não sejam dependências externas como API ou bancos de dados. Para estes momentos existem os testes de unidade do tipo Sociable.

Em resumo, devemos abusar dos testes unitários. Eles devem ser executados rapidamente e servem para testar o comportamento do nosso código a partir de métodos públicos. Analise sempre, caso a caso, qual a melhor opção a ser escolhida: se Solitary ou Sociable, dependendo se queremos testar apenas o nosso método focando nas trocas de estado dos nossos objetos, ou se queremos testar o comportamento do método com todas as chamadas que contenham regras de negócio, mesmo que sejam pertencentes a outras classes.

Integration Test

Testes de integração são aqueles responsáveis por garantir que nosso serviço é capaz de interagir com dependências externas, especialmente outros serviços. Aqui que entram os tão debatidos testes de contrato, que servem para garantir que uma vez que você precisa chamar uma API REST de um serviço, o contrato combinado entre as partes será respeitado.

Os testes de integração poderiam ser realizados a partir de um código automatizado que executa uma chamada REST para um serviço que esteja, de fato rodando. Claro, isso funcionaria perfeitamente. Mas lembre-se da pirâmide. Se fizéssemos desta forma estaríamos construindo um teste end to end, um teste pesado, custoso e com altas chances de dar errado. E se o serviço estiver down? E se a base de dados que este serviço utiliza estiver down? Qual ambiente devo executar este teste, DEV ou HLG? Eu consigo executar este teste no meu pipeline de publicação?

O objetivo de se escrever testes de contrato é, de alguma forma, tentar trazer uma garantia de que o contrato de uma API será respeitado e que este teste pode ser realizado de forma rápida, efetiva e diversas vezes. E é por isso que não vamos escrever testes de integração apontando para o DNS de serviços que estejam rodando em HLG. Vamos utilizar outras abordagens.

Chris Richardson em seu livro Microservices Patterns apresenta três alternativas de padrões de testes de integração para Microservices.

Para fins de entendimento, vamos combinar o seguinte:
Time Consumidor – é o time interessado em utilizar um determinado serviço.
Time Provedor – é o time responsável por fornecer o serviço que o time consumidor precisa.

No primeiro padrão, chamado de Consumer-Driven Contract Test, o time consumidor escreve um teste de contrato com o contrato esperado e acordado entre os times. Sim, é isso mesmo que você leu, quem escreve o teste de contrato é o time consumidor, ou seja, o time interessado naquele endpoint daquele serviço. Depois, o time consumidor pode solicitar que o teste seja adicionado à base de código do serviço que o time provedor cuida através de um Merge Request.

Este teste será executado automaticamente a cada build, de forma rápida e vai garantir que ninguém do time provedor do serviço vai quebrar o contrato que o time consumidor espera. Se o contrato for quebrado será possível identificar antes que isso afete algum interessado. O lado ruim desta abordagem é que, talvez, isso seja um pouco invasivo e torne o controle de Merge Requests complexos para alguns times.

Dentro do Agibank há times que fornecem consultas a muitos interessados, imagine fazer o controle de diversos Merge Requests vindos de equipes diferentes incrementando o repositório do time: e se algum teste vier quebrado? E se vier repetido? Enfim, a chave para este fluxo de processo é, sem dúvida, a comunicação entre equipes.

Uma segunda abordagem é chamada de Consumer-Side Contract Test, por vezes também referida como Provider-Driven Contract Test. A ideia é bem simples de entender: o time provedor é responsável por desenvolver os testes que garantem os contratos de seus endpoints. Esta abordagem é mais simples de se seguir, uma vez que os times que detém o conhecimento do funcionamento dos seus negócios serão responsáveis por garantir os contratos estabelecidos e acordados. O problema dessa abordagem talvez seja simplesmente não escrever os testes necessários – uma vez que os testes não existam e os contratos sejam quebrados, só vamos identificar os problemas que geramos a outros serviços da pior forma possível.

A terceira abordagem é uso de frameworks com foco em resolver problemas de testes de contratos. Dois exemplos que podemos citar são o Pact e o Spring Cloud Contract. O funcionamento das ferramentas pode variar um pouco, mas o conceito geral é que exista uma colaboração mútua entre o time consumidor, o time provedor e a própria ferramenta. O Spring Cloud Contract fornece a possibilidade de escrever contratos utilizando Groovy. O fluxo proposto por Richardson é o seguinte: 

  1. O time consumidor escreve o contrato esperado e passa para o time provedor;
  2. O time provedor utiliza os contratos para gerar testes através do Spring Cloud Contract;
  3. O time provedor publica os contratos testados em um repositório de contratos;
  4. O time consumidor lê os contratos publicados no repositório para desenvolver os testes de contrato necessários em seu próprio repositório;

Pode parecer um pouco confuso, mas a ideia é que o repositório de contratos se mantenha atualizado para que todos os times consumidores tenham garantia de que suas dependências não serão quebradas. 

É importante lembrar que, apesar de todos os exemplos citados acima serem relacionados com APIs REST, podemos e devemos testar contratos de integrações via eventos também! Eventos são parte importante do fluxo dos nossos dados e não podem ser esquecidos, e podemos utilizar qualquer uma das três abordagens apresentadas para escrever testes de contrato para nossos eventos.

Testes de contrato são uma parte extremamente importante do software e muitas vezes são negligenciados. Para entender a gravidade de não escrevermos testes de contrato basta refletir sobre como os maiores desafios de uma empresa, como um banco, estão relacionados a integração entre os serviços.

Integração é ponto vital e vai estar presente em todos os projetos. Arquitetura de Microservices adicionou complexidade neste quesito, então devemos planejar com bons olhos os testes de contratos a serem escritos.

Component Test

Testes de componente são responsáveis por garantir o funcionamento de um serviço como um todo, ou de um determinado domínio. A ideia aqui é escrevermos testes que sejam capazes de analisar o funcionamento do negócio de um serviço por completo.

Mas aí você pode pensar que para atingir este objetivo é possível escrever um teste de ponta a ponta. Bom, sim, um teste de ponta a ponta certamente garantiria que o serviço está funcionando e resolveria nosso problema. Mas ele nos traria diversos outros como “como eu lido com as malditas dependências externas de forma rápida e prática?”. A resposta é não lida. Ou, na verdade, utiliza testes de componente!

A ideia aqui é substituir toda e qualquer dependência externa que um serviço possua por Test Doubles. Depois vamos garantir o funcionamento completo de todos os processos que façam parte do domínio de negócio relacionado ao nosso serviço.

Para isso primeiro temos que definir testes de aceitação. Em testes de componente o uso de Behavior Driven Development é um forte aliado. Identifique cenários e deixe claro os comportamentos esperados. Substitua toda dependência externa como outros serviços e bancos de dados por Test Doubles e escreva testes que garantam o funcionamento do seu serviço de acordo com o comportamento descrito. Abaixo, um exemplo de cenários escrito utilizando BDD:

Existem excelentes ferramentas que podemos usar para BDD, como Cucumber ou Gherkin, mas o mais importante é abusar da boa vontade do PO e da área de negócio. A vantagem de escrever testes com BDD é que a linguagem é de alto nível e pode nos aproximar de pessoas não técnicas que dominam aquilo que mais importa: o conhecimento de como o negócio precisa se comportar.

End to End

Por fim, chegamos nele. O tão temido pelos desenvolvedores e o astro dos antigos profissionais de QA. O teste ponta a ponta. A ideia aqui é muito simples: eu preciso fazer o meu software funcionar para valer.

Testes de componentes são capazes de testar, por completo, serviços de forma individual. Testes de ponta a ponta são capazes de testar o funcionamento de todos os serviços de forma integrada, passando por todas as etapas necessárias para validar o funcionamento da aplicação.

Aqui não existem escapatórias, é preciso garantir que todas as dependências externas estejam UP e prontas para tudo que vier pela frente. Basicamente é preciso escrever um código que vai simular um dia de trabalho comum da operação de negócio com a aplicação que você desenvolveu.

Para atingir o resultado esperado podemos utilizar a mesma estratégia aplicada aos testes de componentes, ou seja, utilizar BDD com ferramentas como Cucumber ou Gherkin, cenários de testes bem definidos e descritos. A diferença, é claro, é que teremos muito mais cenários para validar, além de garantir que todos os serviços envolvidos com o processo estejam prontos para responder as requisições que receberem.

Testes End to End são muito custosos. É preciso garantir que toda a infraestrutura envolvida esteja pronta. É preciso garantir que todas as versões corretas de serviços estejam prontas. É preciso garantir que todos os usuários e acessos de aplicações estejam prontos. Ás vezes é preciso rezar um pouco, também.

E é por isso que testes End to End estão no topo da nossa pirâmide. São demorados, custosos, complexos e com altas chances de falharem. E não devem ser executados com a mesma frequência dos demais. Portanto, de todos os tipos de testes citados neste artigo, são os que devemos escrever menos.

​​​​Conclusão

A Pirâmide de Testes é uma metáfora visual simples que nos ajuda a enxergar como são divididos os testes dado sua complexidade, custo e velocidade de execução.

Com esta informação em mente conseguimos planejar com maior assertividade quais testes precisam ser o foco do nosso tempo a fim de garantir a qualidade de entrega do nosso software.

De baixo para cima na nossa pirâmide os testes se dividem em Unit Test, Integration Test, Component Test e End to End. Quanto mais ao topo da pirâmide mais custoso, mais complexo e mais demorado é um teste. Quanto mais próximo da base menos custoso, menos complexo e mais rápido é um teste.

Todo tipo de teste é importante, tem seu valor e sua função. Não devemos negligenciar nenhum. Porém, devemos tentar nos manter focados naquilo que vai gerar mais retorno dado os prazos e cronograma de cada projeto. Cada caso é um caso!

O mais importante é tentar manter a proporcionalidade! Escreva sempre mais testes de unidade, seguidos de testes de integração, depois componentes e, somente aí pense em escrever um teste de ponta a ponta.

Mas lembre-se: escrever teste é parte fundamental de escrever software.

Referências:

Chris Richardson, Microservice Patterns: With examples in Java

https://microservices.io

https://martinfowler.com/bliki/UnitTest.html

https://martinfowler.com/bliki/TestPyramid.html

https://martinfowler.com/articles/microservice-testing/

%d blogueiros gostam disto: