Traduzido de Practical Test Pyramid, de Ham Vocke, com a anuência do autor.
A "Pirâmide de Teste" é uma metáfora que diz para agrupar testes de software em compartimentos (buckets) de diferentes granularidades. Ela também oferece uma ideia de quantos testes devemos ter em cada um desses compartimentos. Embora o conceito da pirâmide de teste exista há algum tempo, as equipes de desenvolvimento ainda lutam para colocá-lo em prática de forma adequada. Este artigo revisita o conceito original da pirâmide de teste e apresenta como você pode colocá-lo em prática. O artigo também mostra os tipos de teste que você deve procurar em diferentes níveis da pirâmide e oferece exemplos práticos sobre como eles podem ser implementados.
Um software pronto para produção requer testes antes de definitivamente entrar em produção. À medida que a área de desenvolvimento de software amadureceu, as abordagens para teste de software também amadureceram. Ao invés de se ter uma miríade de testadores manuais de software, as equipes de desenvolvimento passaram a automatizar a maior parte de seus esforços com teste de software. Automatizar os testes permite que as equipes saibam se seu software está "quebrado" em questão de segundos e minutos, em vez de dias e semanas.
O ciclo de feedback drasticamente curto, alimentado por testes automatizados, anda de mãos dadas com práticas de desenvolvimento ágil, entrega contínua e cultura DevOps. Ter uma abordagem efetiva para teste de software permite que as equipes se movam rapidamente e com confiança.
Este artigo explora o que um portfólio de teste deve ter para ser considerado responsivo, confiável e manutenível - independentemente se você está construindo uma arquitetura de microsserviços, aplicativos móveis ou ecossistemas de IoT (Internet of Things). Também entraremos em detalhes sobre a criação de testes automatizados efetivos e legíveis.
O software tem se tornado parte essencial do mundo em que vivemos. Ele superou seu único propósito inicial de tornar negócios mais eficientes. Hoje, as empresas tentam encontrar maneiras de se tornarem empresas digitais de primeira classe. Como usuários, todos nós interagimos com uma quantidade cada vez maior de software diariamente. As rodas da inovação estão girando rapidamente.
Se você quiser manter o ritmo, terá que procurar maneiras de entregar software mais rapidamente, sem sacrificar sua qualidade. Entrega contínua, prática na qual você garante automaticamente que seu software pode ser colocado em produção (released) a qualquer momento, pode ajudá-lo com isso. Com a entrega contínua, você usa um pipeline de construção para testar automaticamente seu software e implantá-lo em seus ambientes de teste e produção.
Construir, testar e implantar manualmente uma quantidade cada vez maior de software logo se torna algo impossível - a menos que você queira gastar todo o seu tempo com trabalho manual e repetitivo, ao invés de entregar software funcionando. Automatizar tudo - da construção aos testes, implantação e infraestrutura - é o único caminho a seguir.
Figura 1. Use pipelines de construção para colocar seu software em produção de forma automática e confiável.
Tradicionalmente, o teste de software era um trabalho excessivamente manual, feito ao implantar a aplicação em um ambiente de teste e, então, realizar alguns testes no estilo caixa-preta, por exemplo, clicando através de sua interface de usuário para ver se alguma coisa quebrou. Geralmente, esse testes eram determinados por roteiros (scripts) de teste, para garantir que os testadores fizessem verificações de forma consistente.
É óbvio que testar todas as alterações manualmente é demorado, repetitivo e tedioso. Algo repetitivo é chato e o que é chato leva a erros e faz você procurar um emprego novo no final da semana.
Felizmente, existe um remédio para tarefas repetitivas: automação.
Automatizar seus testes repetitivos pode ser uma grande virada de jogo em sua vida como desenvolvedor de software. Automatize esses testes e você não terá mais que seguir protocolos de cliques sem pensar, a fim de verificar se seu software ainda funciona corretamente. Automatize seus testes e você poderá alterar sua base de código sem titubear. Se você já tentou fazer uma refatoração em larga escala sem uma suíte de teste adequada, aposto que você sabe o quão terrível esta experiência pode ser. Como você saberia se acidentalmente quebrasse alguma coisa ao longo do caminho? Bem, você "clica", de acordo com todos os seus roteiros de teste manuais; é assim. Mas, sejamos honestos: você realmente gosta disso? Que tal realizar alterações em larga escala e saber se você quebrou alguma coisa em segundos, enquanto toma um bom gole de café? Parece mais agradável, não é mesmo?
Se você quiser levar a sério os testes automatizados para o seu software, há um conceito-chave que você deve conhecer: a Pirâmide de Teste. Mike Cohn propôs esse conceito em seu livro Succeeding with Agile: Software Development Using Scrum (traduzido para o português com o título "Desenvolvimento de Software com Scrum: Aplicando Métodos Ágeis com Sucesso"). A pirâmide de teste é uma ótima metáfora visual, pois te faz pensar sobre as diferentes camadas de teste. Ela também informa a você quanto de teste realizar em cada camada.
Figura 2. A Pirâmide de Teste.
A pirâmide de teste original de Mike Cohn consiste em três camadas, as quais sua suíte de teste deve contemplar (de baixo para cima):
- Testes de unidade
- Testes de serviço
- Testes de interface do usuário (User Interface - UI)
Infelizmente, o conceito da pirâmide de teste fica um pouco aquém, se você olhar mais de perto. Algumas pessoas argumentam que a nomenclatura ou alguns aspectos conceituais da pirâmide de teste de Mike Cohn não são ideais, e eu tenho que concordar. De um ponto de vista moderno, a pirâmide de teste parece excessivamente simplista e pode, portanto, ser enganosa.
Ainda assim, devido à sua simplicidade, a essência da pirâmide de teste serve como uma boa regra geral, quando se trata de estabelecer sua própria suíte de teste. O melhor que você pode fazer é se lembrar de duas coisas a respeito da pirâmide de teste original de Cohn:
- Escreva testes com diferentes granularidades
- Quanto mais alto o nível que você estiver, menos testes você deve escrever
Atenha-se ao formato da pirâmide para criar uma suíte de teste saudável, rápida e manutenível: escreva muitos testes de unidade, pequenos e rápidos. Escreva alguns testes mais granulares e muito poucos testes de alto-nível, que testam sua aplicação de ponta-a-ponta. Cuidado para não acabar com uma suíte de teste do tipo casquinha de sorvete (ice-cream cone tests), que será um pesadelo para manter e leva muito tempo para ser executada.
Não se apegue tanto aos nomes das camadas individuais da pirâmide de teste de Cohn. De fato, eles podem ser um pouco enganosos: por exemplo, "testes de serviço" é uma expressão difícil de definir (o próprio Mike Cohn fez uma declaração de que muitos desenvolvedores ignoram completamente esta camada). Além disso, vivemos em dias em que frameworks para SPA (Single Page Applications), tais como React, Angular, Ember.js, entre outros, demonstram que "testes de interface do usuário" não precisam estar no nível mais alto de sua pirâmide - você pode perfeitamente realizar "testes de unidade" em todos esses frameworks.
Dadas essas limitações dos nomes originais, é totalmente aceitável criar outros nomes para suas camadas de teste, desde que você os mantenha consistentes em sua base de código e nas discussões com sua equipe.
- JUnit: nosso executor de testes (test runner)
- Mockito: para substituir (mock) dependências
- Wiremock: para criar stubs de serviços externos
- Pact: para escrever testes CDC
- Selenium: para escrever testes ponta-a-ponta (end-to-end), executados a partir da interface de usuário
- REST-assured: para escrever testes ponta-a-ponta (end-to-end), executados a partir dos endpoints de uma API REST
Eu escrevi um microsserviço simples, incluindo uma suíte com testes para as diferentes camadas da pirâmide de teste.
A aplicação de exemplo apresenta as características de um típico microsserviço. Ela expõe uma interface REST, "conversa" com uma base de dados e busca informações de um serviço REST de terceiros. Ela foi implementada usando Spring Boot e deve ser compreensível mesmo que você nunca tenha trabalho com Spring Boot antes.
Certifique-se de verificar o código no Github. O README do projeto contém as instruções necessárias para você executar a aplicação, bem como seus testes automatizados, em sua máquina.
A funcionalidade da aplicação é simples. Ela disponibiliza uma interface REST com três endpoints:
Endpoint | Funcionalidade |
---|---|
GET /hello |
Retorna "Hello World". Sempre. |
GET /hello/{lastname} |
Busca a pessoa com o sobrenome informado. Se existir, retorna "Hello, {Firstname} {Lastname}". |
GET /weather |
Retorna as condições meteorológicas atuais para Hamburg, Alemanha. |
Em alto-nível, o sistema possui a seguinte estrutura:
Figura 3. Estrutura em alto-nível do nosso microsserviço.
Nosso microsserviço oferece uma interface REST que pode ser invocada via HTTP. Para alguns endpoints, o serviço irá buscar informações de uma base de dados. Em outros casos, o serviço irá invocar uma API externa de previsão do tempo, a fim de recuperar e exibir as condições meteorológicas atuais.
Internamente, nosso microsserviço tem uma arquitetura típica de aplicações Spring:
Figura 4. Estrutura interna do nosso microsserviço.
- As classes do tipo
Controller
provêm endpoints REST e lidam com requisições e respostas HTTP. - As classes do tipo
Repository
interagem com o banco de dados e cuidam da escrita e leitura de dados no/do armazenamento persistente. - As classes do tipo
Client
"conversam" com outras APIs, em nosso caso, elas buscam dados no formato JSON a partir de uma API de previsão do tempo, denominada darksky.net 1. - As classes do tipo
Domain
representam nosso modelo de domínio, incluindo a lógica de negócio (que, para ser justo, é bem trivial em nosso caso).
Os desenvolvedores Spring experientes devem notar que uma camada frequentemente utilizada está faltando aqui: inspirados pelo Domain-Drive Design (DDD), muitos desenvolvedores constroem uma camada para classes de serviço. Eu decidi não incluir uma camada de serviço nesta aplicação. Uma razão é que nossa aplicação é bastante simples, por isso, uma camada de serviço seria um nível de abstração desnecessário. Outro motivo é que eu acho que muitas pessoas exageram em suas camadas de serviço. Geralmente, eu encontro bases de código nas quais toda a regra de negócio está encapsulada em classes de serviço. O modelo de domínio torna-se apenas uma camada de dados, não de comportamentos (isto é, um Modelo de Domínio Anêmico). Para toda aplicação não-trivial, isso desperdiça muito potencial para manter seu código bem estruturado e testável e não utiliza totalmente o poder da orientação a objetos.
Nossos repositórios são simples e diretos, oferecendo a funcionalidade de CRUD. Para manter o código simples, eu usei o Spring Data. Spring Data nos fornece uma implementação de repositório CRUD simples e genérica, a qual podemos usar em vez de desenvolvê-la por nós mesmos. Ele também cuida da criação de um banco de dados em memória para nossos testes, em vez de usar um banco de dados PostgresSQL real, como seria em produção.
Dê uma olhada na base de código para se familiarizar com a estrutura interna. Isso será útil para nossa próxima etapa: testar a aplicação!
A base de sua suíte de teste deve ser composta por testes de unidade. Seus testes de unidade garantem que uma determinada unidade (Subject Under Test - SUT) de sua base de código funciona conforme esperado. Testes de unidade possuem o escopo mais restrito de todos os testes em sua suíte de teste. O número de testes de unidade em sua suíte de teste superará em muito o de qualquer outro tipo de teste.
Figura 5. Um teste de unidade normalmente substitui seus colaboradores externos por dublês de teste (test doubles).
Se você perguntar a três pessoas diferentes o que é "unidade" no contexto de testes de unidade, provavelmente, você receberá quatro respostas ligeiramente diferentes. Até certo ponto, é uma questão de definição pessoal e não há problema em não se ter uma resposta canônica.
Se você estiver trabalhando em uma linguagem funcional, uma unidade será, provavelmente, uma única função. Seus testes de unidade invocarão esta função com diferentes parâmetros e garantirão que ela retorne os valores esperados. Em uma linguagem orientada a objetos, uma unidade pode ser desde um único método até uma classe inteira.
Algumas pessoas defendem que todos os colaboradores (por exemplo, outras classes que são chamadas pela classe sob teste) do seu SUT devem ser substituídas por mocks ou stubs a fim de se obter um isolamento perfeito e evitar efeitos colaterais e uma configuração de teste complicada. Outros argumentam que apenas colaboradores que são "lentos" ou com efeitos colaterais maiores (por exemplo, classes que acessam o banco de dados ou fazem chamadas via rede) devem ser substituídas.
Geralmente, as pessoas nomeiam esses dois tipos de testes como testes de unidade solitários, para os testes que substituem todos os seus colaboradores, e testes de unidade sociáveis, para os testes que permitem "conversar" com colaboradores reais (Jay Fields cunhou esses termos em seu livro Working Effectively with Unit Tests). Se você tiver algum tempo livre, leia mais sobre os prós e contras dessas diferentes escolas de pensamento.
No final das contas, não importa se você vai de testes de unidade solitários ou sociáveis. Escrever testes automatizados é o que importa. Pessoalmente, eu procuro usar ambas as abordagens o tempo todo. Se ficar estranho usar colaboradores reais, então eu os substituirei, sem cerimônia. Se eu sentir que envolver o colaborador real me dá mais confiança em um teste, então eu vou substituir apenas as partes mais externas do meu serviço.
Mocks e stubs são dois tipos diferentes (existem mais do que esses dois) de dublês de teste (test doubles). Muitas pessoas usam os termos mock e stub de forma intercambiável. Acho bom ser preciso e manter em mente suas propriedades específicas. Você pode usar dublê de teste para substituir objetos que você usaria em produção por uma implementação que ajuda nos testes.
Em termos simples, isso significa que você substitui algo real (por exemplo, uma classe, módulo ou função) por uma versão falsa desse algo. A versão falsa parece e age como a coisa real (responde às mesmas chamadas de método), mas responde com respostas predefinidas que você mesmo define no início do seu teste de unidade.
O uso de dublês de teste não é específico para testes de unidade. Dublês de teste mais elaborados podem ser usados para simular partes inteiras do seu sistema de maneira controlada. No entanto, nos testes de unidade, é mais provável que você encontre muitos mocks e stubs (dependendo se você é do tipo sociável ou solitário de desenvolvedor), simplesmente porque muitas linguagens e bibliotecas modernas facilitam e tornam confortável a configuração de mocks e stubs.
Independentemente da sua escolha de tecnologia, há uma boa chance de que tanto a biblioteca padrão da sua linguagem quanto alguma biblioteca de terceiros popular forneçam maneiras elegantes de configurar mocks. E até mesmo escrever seus próprios mocks do zero é apenas uma questão de criar uma classe/módulo/função falsa com a mesma assinatura da real e configurar a falsa no seu teste.
Seus testes de unidade serão executados muito rapidamente. Em uma máquina decente, você pode esperar executar milhares de testes de unidade em poucos minutos. Teste pequenas partes da sua base de código isoladamente e evite acessar bancos de dados, sistema de arquivos ou fazer consultas HTTP (usando mocks e stubs para essas partes) para manter seus testes rápidos.
Assim que você pegar o jeito de escrever testes de unidade, você se tornará cada vez mais fluente nessa prática. Substitua colaboradores externos por stubs, configure alguns dados de entrada, chame o objeto em teste e verifique se o valor retornado é o esperado. Explore o Desenvolvimento Orientado a Testes e deixe que seus testes de unidade guiem o seu desenvolvimento; se aplicado corretamente, pode ajudar você a entrar em um ótimo fluxo e criar um design sólido e sustentável, ao mesmo tempo que produz automaticamente uma suíte de teste abrangente e totalmente automatizada. Ainda assim, não é uma solução mágica. Experimente de verdade e veja se se adapta ao seu estilo.
Uma coisa boa sobre testes de unidade é que você pode escrevê-los para todas as suas classes de código de produção, independentemente de sua funcionalidade ou de qual camada em sua estrutura interna elas pertencem. Você pode criar testes de unidade para controladores (controllers), da mesma forma que pode testar repositórios, classes de domínio ou leitores de arquivos. Simplesmente, siga a regra geral "uma classe de teste por classe de produção" e você terá um bom começo.
Uma classe de teste de unidade deve, pelo menos, testar a interface pública da classe [de produção]. Os métodos privados não podem ser testados, uma vez que você não pode simplesmente invocá-los a partir de uma classe de teste diferente. Métodos protected ou package-private são acessíveis a partir de uma classe de teste (considerando que a estrutura de pacotes da sua classe de teste é a mesma da sua classe de produção), mas testar esses métodos já é "ir longe demais" (para mais detalhes sobre esse assunto, leia a seção "Mas eu realmente preciso testar este método privado?").
Há uma linha tênue quando se trata de escrever testes de unidade: eles devem garantir que todos os caminhos de código não-triviais estão testados (incluindo o caminho feliz e os casos limítrofes). Ao mesmo tempo, eles não devem estar muito próximos à sua implementação.
Por que isso?
Testes que estão muito próximos do código de produção se tornam rapidamente irritantes. Assim que você refatorar seu código de produção (recapitulação rápida: refatorar significa mudar a estrutura interna do seu código sem mudar seu comportamento visível externamente) seus testes de unidade "quebrarão".
Dessa forma, você perde o grande benefício dos testes de unidade: atuar como uma rede de segurança para alterações no código. Você, então, se torna farto desses testes estúpidos falhando toda vez que você refatora, causando mais trabalho do que ajudando; e de quem foi essa ideia estúpida de testar?
O que você faz então? Não reflita sua estrutura interna de código em seus testes de unidade. Em vez disso, teste o comportamento observável. Pense sobre
se eu inserir os valores X e Y, o resultado será Z?
ao invés de
se eu inserir X e Y, o método invocará a classe A primeiro, depois invocará a classe B e retornará o resultado da classe A mais o resultado da classe B
Métodos privado, em geral, devem ser considerados como um detalhe de implementação. É por isso que você nem deve ter vontade de testá-los.
Muitas vezes eu ouço opositores dos testes de unidade (ou TDD) argumentando que escrever testes de unidade é um trabalho sem sentido, uma vez que você precisa testar todos os seus métodos para obter uma alta cobertura de testes. Eles, geralmente, citam cenários nos quais líderes extremamente ansiosos os forçou a escrever testes de unidade para getters e setters e todos os outros tipos de código trivial, a fim de se obter 100% de cobertura de teste.
Há tanta coisa errada nisso.
Sim, você deve testar a interface pública. Mais importante, no entanto, você não testa código trivial. Não se preocupe, Kent Beck disse que que está tudo bem. Você não ganhará nada ao testar simples getters e setters ou outras implementações triviais (por exemplo, sem nenhuma lógica condicional). Economize tempo, essa é mais uma reunião da qual você pode participar, viva!
Uma boa estrutura para todos os seus testes (não limitada apenas a testes de unidade) é esta:
- Configure os dados de teste
- Chame seu método sob teste
- Assegure (assert) que os resultados esperados são retornados
Há um mnemônico legal para lembrar essa estrutura: "Arrange, Act, Assert". Outro que você pode usar, inspirado no BDD (Behavior Driven Development), é a tríade "given, when, then", onde "given" representa a configuração dos dados de teste, "when" representa o método chamado e "then" representa a parte da asserção.
Este padrão pode ser aplicado a outros testes de nível mais alto também. Em todos os casos, eles garantem que seus testes permaneçam consistentes e fáceis de ler. Além disso, testes escritos com essa estrutura em mente tendem a ser mais curtos e expressivos.
Agora que nós sabemos o que testar e como estruturar nossos testes de unidade, nós podemos, finalmente, ver um exemplo real.
Vamos pegar uma versão simplificada da classe ExempleController
:
@RestController
public class ExampleController {
private final PersonRepository personRepo;
@Autowired
public ExampleController(final PersonRepository personRepo) {
this.personRepo = personRepo;
}
@GetMapping("/hello/{lastName}")
public String hello(@PathVariable final String lastName) {
Optional<Person> foundPerson = personRepo.findByLastName(lastName);
return foundPerson
.map(person -> String.format("Hello %s %s!",
person.getFirstName(),
person.getLastName()))
.orElse(String.format("Who is this '%s' you're talking about?",
lastName));
}
}
Um teste de unidade para o método hello(lastname)
pode ser assim:
public class ExampleControllerTest {
private ExampleController subject;
@Mock
private PersonRepository personRepo;
@Before
public void setUp() throws Exception {
initMocks(this);
subject = new ExampleController(personRepo);
}
@Test
public void shouldReturnFullNameOfAPerson() throws Exception {
Person peter = new Person("Peter", "Pan");
given(personRepo.findByLastName("Pan"))
.willReturn(Optional.of(peter));
String greeting = subject.hello("Pan");
assertThat(greeting, is("Hello Peter Pan!"));
}
@Test
public void shouldTellIfPersonIsUnknown() throws Exception {
given(personRepo.findByLastName(anyString()))
.willReturn(Optional.empty());
String greeting = subject.hello("Pan");
assertThat(greeting, is("Who is this 'Pan' you're talking about?"));
}
}
Estamos escrevemos os testes de unidade usando JUnit, o framework de testes "padrão" para Java. Nós usamos Mockito para substituir a classe PersonRepository
real por um stub em nosso teste. Este stub nos permite criar repostas pré-definidas a serem retornadas pelo método substituído neste teste. Utilizar stubs torna o teste mais simples, previsível e nos permite configurar facilmente os dados de teste.
Seguindo a estrutura "arrange, act, assert", nós escrevemos dois testes - um caso positivo e um caso em que a pessoa procurada não foi encontrada. O primeiro caso de teste cria um novo objeto pessoa e diz ao repositório fictício (mocked) para retornar esse objeto quando for invocado com "Pan" como o valor para o parâmetro lastName
. O teste então chama o método que deve ser testado. Finalmente, ele assegura (asserts) que a resposta seja igual à resposta esperada.
O segundo caso de teste trabalha de forma similar, mas testa o cenário em que o método testado não encontra uma pessoa para o parâmetro informado.
Todas as aplicações não-triviais se integrarão com algumas outras partes (bancos de dados, sistemas de arquivos, chamadas de rede para outras aplicações). Ao escrever testes de unidade, estas são, geralmente, as partes que você deixa de fora para obter um melhor isolamento e testes mais rápidos. Ainda assim, sua aplicação irá interagir com outras partes e isso precisa ser testado. Testes de Integração estão aí para ajudar. Eles testam a integração da sua aplicação com todas as partes que "vivem fora" da sua aplicação.
Para seus testes automatizados, isso significa que você não precisa apenas rodar sua própria aplicação, mas também o componente com o qual você está integrando. Se você estiver testando a integração com um banco de dados, você precisará "executar" um banco de dados ao executar seus testes. Para testar se você pode ler arquivos de um disco, você precisa salvar um arquivo em seu disco e carregá-lo em seu teste de integração.
Eu mencionei antes que "testes de unidade" é um termo vago, isso é ainda mais verdade para "testes de integração". Para algumas pessoas, teste de integração significa testar toda a stack de sua aplicação conectada a outras aplicações. Eu gosto de tratar dos testes de integração de forma mais restrita e testar um ponto de integração de cada vez, substituindo serviços externos e bancos de dados separados por dublês de teste. Juntamente com os testes de contrato e a execução de testes de contrato contra esses dublês de testes, bem como as implementações reais, você pode criar testes de integração mais rápidos, mais independentes e geralmente mais fáceis de entender.
Testes de integração restritos vivem na fronteira do seu serviço. Conceitualmente, eles estão sempre desencadeando uma ação que leva à integração com a parte externa (sistemas de arquivo, banco de dados, serviços externos). Um teste de integração de banco de dados ficaria assim:
- Inicie um banco de dados
- Conecte sua aplicação ao banco de dados
- Execute uma função em seu código que escreva dados no banco de dados
- Verifique se os dados esperados foram escritos no banco de dados, lendo os dados do banco de dados
Figura 6. Um teste de integração de banco de dados integra seu código com um banco de dados real.
Outro exemplo, testar se seu serviço se integra com um serviço externo via API REST poderia ser assim:
- Inicie sua aplicação
- Inicie uma instância do seu serviço externo (ou um dublê de teste com mesma interface)
- Execute uma função em seu código que lê a partir da API do serviço externo
- Verifique se sua aplicação pode tratar (parse) a resposta corretamente
Figura 7. Este tipo de teste de integração verifica se sua aplicação pode se comunicar corretamente com um serviço externo.
Seu teste de integração - tal como teste de unidade - pode ser bastante "caixa-branca". Alguns frameworks permitem que você inicie sua aplicação, substituindo (mock) algumas partes dela, para que você possa verificar se as interações ocorreram corretamente.
Escreva testes de integração para todas aspartes do código em que você serializa ou desserializa dados. Isso acontece com mais frequência do que você imagina. Pense em:
- Chamadas para seus serviços via API REST
- Ler e escrever em bancos de dados
- Chamar APIs de outras aplicações
- Ler e escrever em filas
- Escrever no sistema de arquivo
Escrever testes de integração em torno dessas fronteiras garante que a gravação e a leitura de dados desses colaboradores externos funcionam bem.
Ao escrever testes de integração restritos, você deve tentar executar suas dependências externas localmente: crie um banco de dados MySQL local, teste em um sistemas de arquivos ext4 local. Se você estiver integrando com um serviço externo, execute uma instância desse serviço localmente ou crie e execute uma versão falsa (fake) que imita o comportamento do serviço real.
Se não houver uma maneira de executar o serviço de terceiro localmente, você deve optar por executar uma instância dedicada para testes e apontar para essa instância de teste ao executar seus testes de integração. Evite integrar o sistema de produção real com seus testes automatizados. "Explodir" milhares de requisições de teste em um sistema de produção é uma maneira infalível de deixar as pessoas com raiva, pois você está sobrecarregando seus logs (no melhor caso) ou mesmo gerando um ataque DoS em seus serviços (no pior caso). A integração com serviços pela rede é uma característica típica de um teste de integração amplo, que torna seus testes mais lentos e geralmente mais difíceis de escrever.
Com relação à pirâmide de teste, os testes de integração estão em um nível mais alto do que os testes de unidade. A integração de partes lentas, como sistemas de arquivo e bancos de dados, tende a ser muito mais lento do que executar testes de unidade com essas partes substituídas. Eles também podem ser mais difíceis de escrever do que testes de unidade pequenos e isolados, afinal você tem que se preocupar em configurar uma parte externa como uma das etapas dos seus testes. Ainda assim, eles têm a vantagem de dar a você a confiança de que sua aplicação pode funcionar corretamente com todas as partes externas com as quais ela precisa comunicar. Os testes de unidade não podem ajudá-lo com isso.
PersonRepository
é a única classe de repositório na base de código. Ela depende do Spring Data e não possui implementação real. Ela apenas herda da interface CrudRepository
e provê um único cabeçalho de método. O resto é "mágica" do Spring.
public interface PersonRepository extends CrudRepository<Person, String> {
Optional<Person> findByLastName(String lastName);
}
Com a interface CrudRepository
, o Spring Boot oferece um repositório CRUD totalmente funcional com os métodos findOne
, findAll
, save
, update
e delete
. Nossa definição de método personalizado (findByLastName()
) estende essa funcionalidade básica e nos dá uma maneira de buscar pessoas a partir de seu sobrenome. O Spring Data analisa o tipo de retorno e o nome do método e verifica o nome do método em relação a uma convenção de nomenclatura para descobrir o que ele deve fazer.
Embora o Spring Data faça o trabalho pesado de implementar repositórios de banco de dados, eu escrevi um teste de integração de banco de dados. Você pode argumentar que isso é testar o framework, o que é algo que eu devo evitar, pois não é nosso código que estamos testando. Ainda assim, eu acredito que ter pelo menos um teste de integração aqui é crucial. Primeiro, porque ele testa se nosso método personalizado findByLastName
realmente se comporta conforme o esperado. Em segundo lugar, ele prova que nosso repositório usou a configuração do Spring corretamente e pode se conectar ao banco de dados.
Para facilitar a execução dos testes em sua máquina (sem precisar instalar um banco de dados PostgreSQL), nosso teste se conecta um um banco de dados em memória, denominado H2.
Eu defini o H2 como uma dependência de teste no arquivo de construção build.grade
. O arquivo application.properties
no diretório de teste não define qualquer propriedade spring.datasource
. Isso diz ao Spring Data para usar um banco de dados em memória. Ao encontrar o H2 no classpath
da aplicação, ele simplesmente o utiliza durante a execução dos nossos testes.
Ao executar uma aplicação real com o perfil int
(por exemplo, definindo SPRING_PROFILES_ACTIVE=int
como uma variável de ambiente), ela irá se conectar ao banco de dados PostgreSQL conforme especificado no arquivo application-int.properties
.
Eu sei, há muitos detalhes específicos do framework Spring para conhecer e entender. Para isso, você terá que vasculhar muita documentação. O código resultante é fácil de visualizar, mas difícil de entender se você não conhece os detalhes do Spring.
Além disso, utilizar um banco de dados em memória é um negócio arriscado. Afinal, nossos testes de integração são executados em um tipo de banco de dados diferente daquele utilizado em produção. Vá em frente e decida por si mesmo se você prefere a "mágica" do Spring e o código simples em vez de uma implementação explícita e mais verbosa.
Já chega de explicação, aqui está um teste de integração simples que salva uma Person
no banco de dados e a encontra pelo sobrenome:
@RunWith(SpringRunner.class)
@DataJpaTest
public class PersonRepositoryIntegrationTest {
@Autowired
private PersonRepository subject;
@After
public void tearDown() throws Exception {
subject.deleteAll();
}
@Test
public void shouldSaveAndFetchPerson() throws Exception {
Person peter = new Person("Peter", "Pan");
subject.save(peter);
Optional<Person> maybePeter = subject.findByLastName("Pan");
assertThat(maybePeter, is(Optional.of(peter)));
}
}
Você pode ver que nosso teste de integração segue a mesma estrutura Arrange, Act, Assert dos testes de unidade. Eu lhe disse que este era um conceito universal!
Nosso microsserviço se comunica com o darksky.net, uma API REST de meteorologia. É claro que queremos garantir que nosso serviço mande requisições e analise as respostas corretamente.
Queremos evitar acessar os servidores reais do darksky ao executar testes automatizados. Os limites de cota do nosso plano gratuito são apenas parte do motivo. A verdadeira razão é desacoplamento. Nossos testes devem ser executados independentemente do que as pessoas amáveis do darksky.net estejam fazendo. Mesmo quando sua máquina não consiga acessar os servidores da darksky ou os servidores da darksky estejam fora do ar para manutenção.
Podemos evitar acessar os servidores reais da darksky executando nosso próprio servidor falso enquanto executamos nossos testes de integração. Isso pode soar como uma tarefa enorme. Graças a ferramentas como o Wiremock, é fácil fácil. Veja só:
@RunWith(SpringRunner.class)
@SpringBootTest
public class WeatherClientIntegrationTest {
@Autowired
private WeatherClient subject;
@Rule
public WireMockRule wireMockRule = new WireMockRule(8089);
@Test
public void shouldCallWeatherService() throws Exception {
wireMockRule.stubFor(get(urlPathEqualTo("/some-test-api-key/53.5511,9.9937"))
.willReturn(aResponse()
.withBody(FileLoader.read("classpath:weatherApiResponse.json"))
.withHeader(CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.withStatus(200)));
Optional<WeatherResponse> weatherResponse = subject.fetchWeather();
Optional<WeatherResponse> expectedResponse = Optional.of(new WeatherResponse("Rain"));
assertThat(weatherResponse, is(expectedResponse));
}
}
Para usar o Wiremock, instanciamos um WireMockRule
em uma porta fixa (8089)
. Usando uma DSL (Domain-Specific Language), podemos configurar o servidor Wiremock, definir os endpoints que ele deve escutar e definir os modelos de resposta com os quais ele deve responder.
Em seguida, chamamos o método que queremos testar, aquele que chama o serviço externo, e verificamos se o resultado foi analisado corretamente.
É importante entender como o teste sabe que deve chamar o servidor Wiremock falso em vez da API verdadeira do darksky. O segredo está em nosso arquivo application.properties
contido em src/test/resources
. Este é o arquivo de propriedades que o Spring carrega ao executar testes. Neste arquivo, substituímos configurações como chaves de API e URLs por valores adequados para nossos propósitos de teste, por exemplo, chamando o servidor Wiremock falso ao invés do real:
weather.url = http://localhost:8089
Observe que a porta definida aqui deve ser a mesma que definimos ao instanciar o WireMockRule
em nosso teste. Substituir a URL real da API de tempo por uma falso em nossos testes é possível injetando a URL no construtor de nossa classe WeatherClient
:
@Autowired
public WeatherClient(final RestTemplate restTemplate,
@Value("${weather.url}") final String weatherServiceUrl,
@Value("${weather.api_key}") final String weatherServiceApiKey) {
this.restTemplate = restTemplate;
this.weatherServiceUrl = weatherServiceUrl;
this.weatherServiceApiKey = weatherServiceApiKey;
}
Desta forma, pedimos ao nosso WeatherClient
para ler o valor do parâmetro weatherUrl
da propriedade weather.url
que definimos nas propriedades de nosso aplicativo.
Escrever testes de integração restritos para um serviço separado é bastante fácil com ferramentas como o Wiremock. Infelizmente, há uma desvantagem nessa abordagem: como garantimos que o servidor falso que configuramos se comporta como o servidor real? Com a implementação atual, o serviço separado poderia mudar sua API e nossos testes ainda passariam. No momento, estamos apenas testando se nosso WeatherClient
pode analisar as respostas que o servidor falso envia. Isso é um começo, mas é muito frágil. Usar testes de ponta-a-ponta e executá-los em uma instância de teste do serviço real ao invés de usar um serviço falso resolveria esse problema, mas nos tornaria dependentes da disponibilidade do serviço de teste.
Felizmente, há uma solução melhor para esse dilema: executar testes de contrato no servidor falso, assim, o servidor real garante que o falso que usamos em nossos testes de integração seja um dublê fiel. Vamos ver como isso funciona em seguida.
Organizações de desenvolvimento de software mais modernas encontraram maneiras de escalar seus esforços de desenvolvimento distribuindo o desenvolvimento de um sistema entre diferentes equipes. As equipes individuais constroem serviços individuais, fracamente acoplados, sem interferir uns nos outros, e integram esses serviços em um grande sistema coeso. O recente interesse em torno dos microsserviços foca exatamente nisso.
Dividir o sistema em muitos serviços pequenos muitas vezes significa que esses serviços precisam se comunicar entre si por meio de interfaces específicas (esperançosamente bem definidas, às vezes crescidas de forma acidental).
As interfaces entre diferentes aplicativos podem vir em diferentes formatos e tecnologias. Os mais comuns são:
- REST e JSON via HTTPS
- RPC usando algo como gRPC
- construção de uma arquitetura orientada a eventos usando filas
Para cada interface, há duas partes envolvidas: o provedor e o consumidor. O provedor serve dados aos consumidores. O consumidor processa dados obtidos de um provedor. Em um mundo REST, um provedor constrói uma API REST com todos os endpoints necessários; um consumidor faz chamadas a essa API REST para buscar dados ou acionar mudanças no outro serviço. Em um mundo assíncrono orientado a eventos, um provedor (geralmente chamado de publicador) publica dados em uma fila; um consumidor (geralmente chamado de assinante) se inscreve nessas filas e lê e processa dados.
Figura 8. Cada interface tem um provedor (ou publicador) e um consumidor (ou assinante). A especificação de uma interface pode ser considerada um contrato.
Como os serviços de consumo e fornecimento são frequentemente distribuídos por diferentes equipes, você se encontra na situação em que precisa especificar claramente a interface entre esses serviços (o chamado contrato). Tradicionalmente, as empresas abordaram esse problema da seguinte maneira:
- Escrever uma especificação de interface longa e detalhada (o contrato)
- Implementar o serviço provedor de acordo com o contrato definido
- Jogar a especificação de interface para a equipe de consumo
- Aguardar até que eles implementem a parte deles do consumo da interface
- Executar alguns testes manuais em larga escala do sistema para ver se tudo funciona
- Esperar que ambas as equipes sigam a definição da interface para sempre e não estraguem tudo
As equipes de desenvolvimento de software mais modernas substituíram as etapas 5 e 6 por algo mais automatizado: Testes de contrato automatizados garantem que as implementações no lado do consumidor e do provedor ainda sigam o contrato definido. Eles funcionam como uma boa suíte de teste de regressão e garantem que as variações do contrato serão notadas precocemente.
Em uma organização mais ágil, você deve seguir a rota mais eficiente e menos desperdiçadora. Você desenvolve suas aplicações dentro da mesma organização. Realmente não deve ser muito difícil falar diretamente com os desenvolvedores de outros serviços em vez de lançar documentação excessivamente detalhada por cima do muro. Afinal, eles são seus colegas de trabalho e não um provedor terceirizado com o qual você só pode falar via suporte ao cliente ou contratos à prova de balas.
Os testes de contrato orientados pelo consumidor (CDC - Consumer-Driven Contract Tests) permitem que os consumidores conduzam a implementação de um contrato. Usando CDC, os consumidores de uma interface escrevem testes que verificam a interface para todos os dados que eles precisam dessa interface. A equipe consumidora, então, publica esses testes para que a equipe provedora possa buscar e executar esses testes facilmente. A equipe provedora agora pode desenvolver sua API executando os testes CDC. Depois que todos os testes passam, eles sabem que implementaram tudo o que a equipe consumidora precisa.
Figura 9. Os testes de contrato garantem que o provedor e todos os consumidores de uma interface sigam o contrato de interface definido. Com os testes CDC, os consumidores de uma interface publicam seus requisitos na forma de testes automatizados; os provedores buscam e executam esses testes continuamente.
Esta abordagem permite que a equipe provedora implemente apenas o que é realmente necessário (mantendo as coisas simples, YAGNI e tudo mais). A equipe que fornece a interface deve buscar e executar continuamente esses testes CDC (em seu pipeline de construção) para detectar imediatamente quaisquer alterações significativas. Se eles quebrarem a interface, seus testes CDC falharão, impedindo que as alterações entrem em produção. Enquanto os testes permanecerem verdes, a equipe pode fazer quaisquer mudanças que desejar sem se preocupar com outras equipes. A abordagem de contrato orientado pelo consumidor deixaria você com um processo parecido com este:
- A equipe consumidora escreve testes automatizados com todas as expectativas do consumidor
- Eles publicam os testes para a equipe provedora
- A equipe provedora executa os testes CDC continuamente e os mantém verdes
- Ambas as equipes conversam entre si quando os testes CDC falham
Se sua organização adota uma abordagem de microsserviços, ter testes CDC é um grande passo para estabelecer equipes autônomas. Os testes CDC são uma maneira automatizada de fomentar a comunicação entre as equipes. Eles garantem que as interfaces entre equipes estejam funcionando o tempo todo. Testes CDC falhando são um bom indicador de que você deve conversar com a equipe afetada, falar sobre quaisquer mudanças de API iminentes e descobrir como você quer seguir em frente.
Uma implementação ingênua de testes CDC pode ser tão simples quanto enviar solicitações contra uma API e afirmar que as respostas contêm tudo o que você precisa. Então, você empacota esses testes como um executável (.gem, .jar, .sh) e os armazena em algum lugar onde a outra equipe possa acessá-lo (por exemplo, um repositório de artefatos como o Artifactory).
Nos últimos anos, a abordagem CDC se tornou cada vez mais popular e várias ferramentas foram construídas para tornar a escrita e a troca delas mais fáceis.
Pact é provavelmente o mais proeminente nos dias de hoje. Ele tem uma abordagem sofisticada de escrever testes para o lado do consumidor e do provedor, fornece stubs para serviços externos prontos para uso e permite que você troque testes CDC com outras equipes. O Pact foi portado para muitas plataformas e pode ser usado com linguagens JVM, Ruby, .NET, JavaScript e muitas outras.
Se você quer começar com os CDCs e não sabe como, o Pact pode ser uma escolha sensata. A documentação pode ser esmagadora no começo. Seja paciente e trabalhe nisso. Isso ajuda a ter uma compreensão sólida dos CDCs, o que por sua vez torna mais fácil para você defender o uso de CDCs ao trabalhar com outras equipes.
Os testes de contrato orientados pelo consumidor podem ser uma mudança real de jogo para estabelecer equipes autônomas que possam se mover rapidamente e com confiança. Faça um favor a si mesmo, leia sobre esse conceito e experimente. Um conjunto sólido de testes CDC é inestimável para poder se mover rapidamente sem quebrar outros serviços e causar muita frustração em outras equipes.
Nosso microserviço consome a API de meteorologia. Portanto, é nossa responsabilidade escrever um teste do consumidor que defina nossas expectativas para o contrato (a API) entre nosso microserviço e o serviço de meteorologia.
Primeiro, incluímos uma biblioteca para escrever testes de consumidores do Pact em nosso build.gradle
:
testCompile('au.com.dius:pact-jvm-consumer-junit_2.11:3.5.5')
Graças a essa biblioteca, podemos implementar um teste do consumidor e usar os serviços simulados do Pact:
@RunWith(SpringRunner.class)
@SpringBootTest
public class WeatherClientConsumerTest {
@Autowired
private WeatherClient weatherClient;
@Rule
public PactProviderRuleMk2 weatherProvider =
new PactProviderRuleMk2("weather_provider", "localhost", 8089, this);
@Pact(consumer="test_consumer")
public RequestResponsePact createPact(PactDslWithProvider builder) throws IOException {
return builder
.given("weather forecast data")
.uponReceiving("a request for a weather request for Hamburg")
.path("/some-test-api-key/53.5511,9.9937")
.method("GET")
.willRespondWith()
.status(200)
.body(FileLoader.read("classpath:weatherApiResponse.json"),
ContentType.APPLICATION_JSON)
.toPact();
}
@Test
@PactVerification("weather_provider")
public void shouldFetchWeatherInformation() throws Exception {
Optional<WeatherResponse> weatherResponse = weatherClient.fetchWeather();
assertThat(weatherResponse.isPresent(), is(true));
assertThat(weatherResponse.get().getSummary(), is("Rain"));
}
}
Se você observar atentamente, verá que o WeatherClientConsumerTest
é muito semelhante ao WeatherClientIntegrationTest
. Em vez de usar o Wiremock
para o stub do servidor, usamos o Pact desta vez. Na verdade, o teste do consumidor funciona exatamente como o teste de integração; substituímos o servidor real de terceiros por um stub, definimos a resposta esperada e verificamos se nosso cliente pode analisar a resposta corretamente. Nesse sentido, o WeatherClientConsumerTest
é um teste de integração restrito. A vantagem sobre o teste baseado em Wiremock é que este teste gera um arquivo Pact (encontrado em target / pacts / & pact-name> .json
) sempre que é executado. Este arquivo descreve nossas expectativas para o contrato em um formato JSON especial. Esse arquivo Pact pode então ser usado para verificar se nosso servidor de stub se comporta como o servidor real. Podemos pegar o arquivo Pact e entregá-lo à equipe que fornece a interface. Eles pegam esse arquivo e escrevem um teste do provedor usando as expectativas definidas lá. Dessa forma, eles testam se sua API atende a todas as nossas expectativas.
Você vê que é aqui que a parte orientada pelo consumidor do CDC vem. O consumidor orienta a implementação da interface descrevendo suas expectativas. O provedor deve garantir que eles atendam a todas as expectativas e pronto. Sem exagero, sem YAGNI e coisas assim.
Enviar o arquivo Pact para a equipe provedora pode ser feito de várias maneiras. Uma simples é colocá-lo no controle de versão e dizer à equipe provedora para sempre buscar a versão mais recente do arquivo. Uma mais avançada é usar um repositório de artefatos, um serviço como o S3 da Amazon ou o broker do Pact. Comece simples e cresça conforme sua necessidade.
Em sua aplicação do mundo real, você não precisa de um teste de integração e um teste do consumidor para uma classe de cliente. A base de código de exemplo contém ambos para mostrar como usar um ou outro. Se você deseja escrever testes CDC usando pact, recomendo aderir ao último. O esforço de escrever os testes é o mesmo. Usar Pact tem a vantagem de que você automaticamente obtém um arquivo pact com as expectativas do contrato que outras equipes podem usar para implementar facilmente seus testes de provedor. Claro, isso só faz sentido se você puder convencer a outra equipe a usar o Pact também. Se isso não funcionar, usar a combinação de teste de integração e Wiremock; é um plano B decente.
O teste do provedor precisa ser implementado pelas pessoas que fornecem a API de meteorologia. Estamos consumindo uma API pública fornecida pela darksky.net. Em teoria, a equipe da darksky implementaria o teste do provedor em seu próprio sistema para verificar se não estão quebrando o contrato entre sua aplicação e nosso serviço.
Obviamente, eles não se importam com nossa modesta aplicação de exemplo e não vão implementar um teste CDC para nós. Essa é a grande diferença entre uma API voltada ao público e uma organização adotando microserviços. APIs públicas não podem considerar todos os consumidores lá fora, caso contrário, ficariam impossibilitadas de avançar. Dentro de sua própria organização, você pode - e deve. Seu aplicativo provavelmente atenderá a um punhado, talvez algumas dúzias de consumidores no máximo. É recomendável que você escreva testes do provedor para essas interfaces, a fim de manter um sistema estável.
A equipe provedora recebe o arquivo Pact e o executa contra seu serviço de fornecimento. Para isso, eles implementam um teste do provedor que lê o arquivo Pact, define alguns dados de teste e executa as expectativas definidas no arquivo Pact em seu serviço.
Os desenvolvedores do Pact escreveram várias bibliotecas para implementar testes do provedor. Seu repositório principal no GitHub fornece uma boa visão geral de quais bibliotecas de consumidor e provedor estão disponíveis. Escolha aquela que melhor se adapta à sua stack tecnológica.
Para simplificar, vamos assumir que a API da darksky é implementada em Spring Boot também. Nesse caso, eles poderiam usar o provedor de contrato do Spring, que se integra perfeitamente aos mecanismos do MockMVC do Spring. Um teste de provedor hipotético que a equipe da darksky.net implementaria poderia ser assim:
@RunWith(RestPactRunner.class)
@Provider("weather_provider") // mesmo que o "provider_name" em nosso clientConsumerTest
@PactFolder("target/pacts") // informa ao Pact onde estão os arquivos Pact que devem ser carregados
public class WeatherProviderTest {
@InjectMocks
private ForecastController forecastController = new ForecastController();
@Mock
private ForecastService forecastService;
@TestTarget
public final MockMvcTarget target = new MockMvcTarget();
@Before
public void before() {
initMocks(this);
target.setControllers(forecastController);
}
@State("weather forecast data") // mesmo que "given()" no nosso clientConsumerTest
public void weatherForecastData() {
when(forecastService.fetchForecastFor(any(String.class), any(String.class)))
.thenReturn(weatherForecast("Rain"));
}
}
Você percebe que tudo o que o teste do provedor tem que fazer é carregar um arquivo de Pact (por exemplo, usando a anotação @PactFolder
para carregar arquivos de Pact previamente baixados) e, em seguida, definir como os dados de teste para estados predefinidos devem ser fornecidos (por exemplo, usando mocks). Não há teste personalizado a ser implementado. Todos são derivados do arquivo Pact. É importante que o teste do provedor tenha correspondência ao nome do provedor e ao estado declarado no teste do consumidor.
Vimos como testar o contrato entre nosso serviço e o provedor de meteorologia. Com esta interface, nosso serviço age como consumidor, e o serviço de meteorologia age como provedor. Pensando um pouco mais, veremos que nosso serviço também age como provedor para outros: fornecemos uma API REST que oferece alguns endpoints prontos para serem consumidos por outros.
Como acabamos de aprender que os testes de contrato são a moda do momento, é claro que escrevemos um teste de contrato para este contrato também. Felizmente, estamos usando contratos orientados pelo consumidor, então há todas as equipes consumidoras nos enviando seus arquivos Pact que podemos usar para implementar nossos testes de provedor para nossa API REST.
Vamos primeiro adicionar a biblioteca de provedor Pact para Spring ao nosso projeto:
testCompile('au.com.dius:pact-jvm-provider-spring_2.12:3.5.5')
Implementar o teste do provedor segue o mesmo padrão descrito anteriormente. Por uma questão de simplicidade, eu simplesmente adicionei o arquivo Pact do nosso consumidor simples ao repositório do nosso serviço. Isso torna mais fácil para o nosso propósito, mas em um cenário da vida real, provavelmente você usaria um mecanismo mais sofisticado para distribuir seus arquivos Pact.
@RunWith(RestPactRunner.class)
@Provider("person_provider") // mesmo que a parte "provider_name" em nosso arquivo Pact
@PactFolder("target/pacts") // informa ao Pact onde estão os arquivos Pact que devem ser carregados
public class ExampleProviderTest {
@Mock
private PersonRepository personRepository;
@Mock
private WeatherClient weatherClient;
private ExampleController exampleController;
@TestTarget
public final MockMvcTarget target = new MockMvcTarget();
@Before
public void before() {
initMocks(this);
exampleController = new ExampleController(personRepository, weatherClient);
target.setControllers(exampleController);
}
@State("person data") // mesmo que "given()" no nosso teste de consumidor
public void personData() {
Person peterPan = new Person("Peter", "Pan");
when(personRepository.findByLastName("Pan")).thenReturn(Optional.of
(peterPan));
}
}
O ExampleProviderTest
mostrado precisa fornecer o estado de acordo com o arquivo de contrato que nos é fornecido. Assim que executamos o teste do provedor, o Pact irá pegar o arquivo de contrato e disparar uma solicitação HTTP contra nosso serviço, que responderá de acordo com o estado que configuramos.
A maioria dos aplicativos tem algum tipo de interface de usuário. Normalmente, estamos falando de uma interface da Web, no contexto de web apps. Muitas vezes, as pessoas esquecem que uma API REST ou uma interface de linha de comando é tanto uma interface de usuário quanto uma interface de usuário da web sofisticada.
Testes de UI testam se a interface de usuário do seu aplicativo funciona corretamente. A entrada do usuário deve acionar as ações corretas, os dados devem ser apresentados ao usuário, o estado da interface de usuário deve mudar conforme o esperado.
Testes de UI e Testes Ponta-a-Ponta (End-To-End) às vezes (como no caso de Mike Cohn) dizem a mesma coisa. Para mim, isso combina duas coisas que são conceitos bastante ortogonais.
Sim, testar seu aplicativo de ponta-a-ponta geralmente significa conduzir seus testes por meio da interface do usuário. O inverso, porém, não é verdadeiro.
O teste da interface de usuário não precisa ser feito de ponta-a-ponta. Dependendo da tecnologia que você usa, testar sua interface de usuário pode ser tão simples quanto escrever alguns testes de unidade para seu código JavaScript front-end com seu back-end eliminado.
Com os aplicativos Web tradicionais, o teste da interface do usuário pode ser obtido com ferramentas como o Selenium. Se você considera uma API REST como sua interface de usuário, deve ter tudo o que precisa escrevendo testes de integração adequados em torno de sua API.
Com interfaces Web, há vários aspectos que você provavelmente deseja testar em torno de sua interface do usuário: comportamento, layout, usabilidade ou aderência ao design corporativo são apenas alguns.
Felizmente, testar o comportamento da interface de usuário é bastante simples. Você clica aqui, insere dados lá e deseja que o estado da interface de usuário mude de acordo. As estruturas modernas de aplicativos SPA (Single-Page Applications) - React, Vue.js, Angular e similares - geralmente vêm com suas próprias ferramentas e auxiliares que permitem que você teste completamente essas interações em um nível bastante baixo (teste de unidade). Mesmo se você lançar sua própria implementação de front-end usando JavaScript puro (vanilla), poderá usar suas ferramentas de teste tradicionais, como Jasmine ou Mocha. Com um aplicativo tradicional, renderizado do lado do servidor, os testes baseados em Selenium serão sua melhor escolha.
Testar se o layout do seu aplicativo da Web permanece intacto é um pouco mais difícil. Dependendo de seu aplicativo e das necessidades de seus usuários, você pode querer certificar-se de que as alterações de código não quebrem o layout do site por acidente.
O problema é que os computadores são notoriamente ruins em verificar se algo "parece bom" (talvez algum algoritmo inteligente de aprendizado de máquina possa mudar isso no futuro).
Existem algumas ferramentas para tentar se você quiser verificar automaticamente o design do seu aplicativo Web em seu pipeline de construção. A maioria dessas ferramentas utiliza o Selenium para abrir seu aplicativo da Web em diferentes navegadores e formatos, fazer capturas de tela e compará-las com capturas de tela feitas anteriormente. Se as capturas de tela antigas e novas diferirem de maneira inesperada, a ferramenta informará você.
Galen é uma dessas ferramentas. Mas, mesmo lançar sua própria solução não é muito difícil se você tiver requisitos especiais. Algumas equipes com as quais trabalhei construíram lineup e seu primo jlineup baseado em Java para conseguir algo semelhante. Ambas as ferramentas adotam a mesma abordagem baseada em Selenium que descrevi anteriormente.
Uma vez que você deseja testar a usabilidade e um fator de "boa aparência", você deixa os domínios dos testes automatizados. Esta é a área em que você deve contar com testes exploratórios, testes de usabilidade (isso pode até ser tão simples quanto hallway testing) e vitrines com seus usuários para ver se eles gostam de usar seu produto e podem usar todos os recursos sem ficarem frustrados ou irritados.
Testar sua aplicação por meio de sua interface de usuário é a maneira mais completa de testar sua aplicação. Os testes de UI orientados por webdriver descritos anteriormente são um bom exemplo de testes ponta-a-ponta.
Figura 11. Testes ponta-a-ponta testam completamente o seu sistema
Os testes ponta-a-ponta (também chamados de Broad Stack Tests ) fornecem maior confiança quando você precisa decidir se seu software está funcionando ou não. O Selenium e o protocolo WebDriver permitem que você automatize seus testes conduzindo automaticamente um navegador (headless) contra seus serviços implantados, realizando cliques, inserindo dados e verificando o estado de sua interface de usuário. Você pode usar o Selenium diretamente ou usar ferramentas construídas sobre ele, Nightwatch sendo uma delas.
Os testes ponta-a-ponta vêm com seus próprios tipos de problemas. Eles são notoriamente incertos e muitas vezes falham por razões inesperadas e imprevisíveis. Frequentemente, sua falha é um falso positivo. Quanto mais sofisticada sua interface de usuário, mais inconstantes os testes tendem a se tornar. Peculiaridades do navegador, problemas de tempo, animações e caixas de diálogo pop-up inesperadas são apenas alguns dos motivos que me fizeram gastar mais tempo com depuração do que gostaria de admitir.
Em um mundo de microsserviços, existe também a grande questão sobre quem está encarregado de escrever estes testes. Já que são abrangidos vários serviços (o sistema inteiro) não existe apenas um time responsável pela escrita de testes ponta-a-ponta.
Se você dispõe de um time centralizado de garantia de qualidade (Quality Assurance - QA) esta pareceria uma opção conveniente. Mas, novamente, lançar mão de um time centralizado de QA é um grande anti-padrão e não deveria acontecer em um universo DevOps, no qual as equipes deveriam ser realmente multifuncionais. Não existe resposta fácil sobre quem deveriar estar encarregado de testes ponta-a-ponta. Talvez sua organização tenha uma comunidade de prática ou um Quality Guild que poderia cuidar disso. Encontrar a resposta correta depende muito da sua organização.
Além disso, testes ponta-a-ponta requerem muito mais manutenção e são de execução lenta. Se imaginarmos uma situação com pouco mais que alguns microsserviços em execução não seria possível nem rodar os testes ponta-a-ponta localmente - já que isso necessitaria que todos os microsserviços também fossem executados localmente. Boa sorte tentando rodar centenas de aplicações na sua máquina sem fritar sua RAM.
Devido aos altos custos de manutenção você deve ter como objetivo reduzir o número de testes ponta-a-ponta ao mínimo possível.
Pense nas interações de alto valor que os usuários terão com seu aplicativo. Tente criar jornadas do usuário que definam o valor principal do seu produto e traduza as etapas mais importantes dessas jornadas em testes automatizados ponta-a-ponta.
Se você estiver construindo um site de e-commerce, sua jornada de cliente mais valiosa pode ser um usuário que procura um produto, o coloca na cesta de compras e faz o check-out. É isso. Enquanto esta jornada funcionar, você não deve ter muitos problemas. Talvez você encontre mais uma ou duas jornadas de usuário cruciais que possam ser traduzidas em testes ponta-a-ponta. Tudo a mais do que isso provavelmente será mais doloroso do que útil.
Lembre-se: você tem muitos níveis inferiores em sua pirâmide de teste, onde já testou todos os tipos de casos extremos e integrações com outras partes do sistema. Não há necessidade de repetir esses testes em um nível superior. Alto esforço de manutenção e muitos falsos positivos irão atrasá-lo e fazer com que você perca a confiança em seus testes, mais cedo ou mais tarde.
Para testes de ponta a ponta, o Selenium e o protocolo WebDriver são a ferramenta de escolha para muitos desenvolvedores. Com o Selenium, você pode escolher um navegador de sua preferência e deixá-lo chamar automaticamente o seu site, clicar aqui e ali, inserir dados e verificar se as coisas mudam na interface do usuário.
O Selenium precisa de um navegador que ele possa iniciar e usar para executar seus testes. Existem vários chamados "drivers" para diferentes navegadores que você poderia usar. Escolha um (ou vários) e adicione-o ao seu arquivo build.gradle
. Independentemente do navegador que você escolher, você precisa garantir que todos os desenvolvedores em sua equipe e seu servidor de integração contínua (Continuous Integration - CI) tenham instalado localmente a versão correta do navegador. Isso pode ser bastante difícil de manter sincronizado. Para Java, existe uma biblioteca pequena e agradável chamada webdrivermanager que pode automatizar o download e a configuração da versão correta do navegador que você deseja usar. Adicione essas duas dependências ao seu arquivo build.gradle
e você está pronto para começar:
testCompile('org.seleniumhq.selenium:selenium-chrome-driver:2.53.1')
testCompile('io.github.bonigarcia:webdrivermanager:1.7.2')
Executar um navegador completo em sua suíte de testes pode ser um incômodo. Especialmente ao usar entrega contínua (Continuous Delivery - CD), o servidor que executa seu pipeline pode não ser capaz de iniciar um navegador com interface de usuário (por exemplo, porque não há um X-Server disponível). Você pode contornar esse problema iniciando um X-Server virtual, como o xvfb.
Uma abordagem mais recente é usar um navegador sem interface de usuário, conhecido como navegador headless, para executar seus testes do webdriver. Até recentemente, o PhantomJS era o principal navegador headless usado para automação de navegador. Desde que tanto o Chromium quanto o Firefox anunciaram que implementaram um modo headless em seus navegadores, o PhantomJS de repente se tornou obsoleto. Afinal, é melhor testar seu site com um navegador que seus usuários realmente usam (como Firefox e Chrome) em vez de usar um navegador artificial, apenas porque é conveniente para você como desenvolvedor.
Ambos, Firefox e Chrome headless, são novos e ainda não foram amplamente adotados para implementar testes de webdriver. Queremos manter as coisas simples. Em vez de mexer com os modos headless de última geração, vamos nos ater ao modo clássico usando Selenium e um navegador regular. Um teste simples de ponta-a-ponta que inicia o Chrome, navega até nosso serviço e verifica o conteúdo do site se parece com isso:
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class HelloE2ESeleniumTest {
private WebDriver driver;
@LocalServerPort
private int port;
@BeforeClass
public static void setUpClass() throws Exception {
ChromeDriverManager.getInstance().setup();
}
@Before
public void setUp() throws Exception {
driver = new ChromeDriver();
}
@After
public void tearDown() {
driver.close();
}
@Test
public void helloPageHasTextHelloWorld() {
driver.get(String.format("http://127.0.0.1:%s/hello", port));
assertThat(driver.findElement(By.tagName("body")).getText(), containsString("Hello World!"));
}
}
Observe que este teste somente será executado se você tiver o Chrome instalado no sistema em que você está rodando os testes (sua máquina local, seu servidor de CI).
O teste é direto. Ele inicializa a aplicação Spring inteira em uma porta aleatória usando @SpringBootTest
. Em seguida, instanciamos um novo webdriver do Chrome, dizemos a ele para navegar até o endpoint /hello
de nosso microserviço e verificamos se ele imprime "Hello World!" na janela do navegador. Que coisa legal!
Evitar uma interface gráfica de usuário ao testar seu aplicativo pode ser uma boa ideia para criar testes que sejam menos regressivos do que testes ponta-a-ponta completos e, ao mesmo tempo, cobrir uma ampla parte da stack de sua aplicação. Isso pode ser útil quando testar por meio da interface web da sua aplicação for particularmente difícil. Talvez você nem tenha uma interface de usuário Web, porém hospeda uma API REST (porque você tem uma SPA em algum lugar conversando com essa API ou simplesmente porque despreza tudo que é bonito e brilhante). De qualquer forma, um teste subcutâneo que testa logo abaixo da interface gráfica do usuário pode levar você muito longe, sem comprometer muito a confiança. É a coisa certa a se fazer se você estiver hospedando uma API REST como fizemos em nosso código de exemplo:
@RestController
public class ExampleController {
private final PersonRepository personRepository;
// abreviado para maior clareza
@GetMapping("/hello/{lastName}")
public String hello(@PathVariable final String lastName) {
Optional<Person> foundPerson = personRepository.findByLastName(lastName);
return foundPerson
.map(person -> String.format("Hello %s %s!",
person.getFirstName(),
person.getLastName()))
.orElse(String.format("Who is this '%s' you're talking about?",
lastName));
}
}
Deixe-me mostrar mais uma biblioteca que é útil ao testar um serviço que fornece uma API REST. REST-assured é uma biblioteca que oferece uma boa DSL para disparar requisições HTTP reais para uma API e avaliar as respostas que você recebe.
Comecemos pelo princípio: adicione a dependência ao seu build.gradle
.
testCompile('io.rest-assured:rest-assured:3.0.3')
Com esta biblioteca em mãos podemos implementar um teste ponta-a-ponta para nossa API REST:
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class HelloE2ERestTest {
@Autowired
private PersonRepository personRepository;
@LocalServerPort
private int port;
@After
public void tearDown() throws Exception {
personRepository.deleteAll();
}
@Test
public void shouldReturnGreeting() throws Exception {
Person peter = new Person("Peter", "Pan");
personRepository.save(peter);
when()
.get(String.format("http://localhost:%s/hello/Pan", port))
.then()
.statusCode(is(200))
.body(containsString("Hello Peter Pan!"));
}
}
Novamente, iniciamos todo o aplicativo Spring usando @SpringBootTest
. Nesse caso, nós usamos @Autowire
sobre PersonRepository
para que possamos gravar dados de teste em nosso banco de dados facilmente. Quando agora pedimos à API REST para dizer "Hello" ao nosso amigo "Sr. Pan"”", estamos sendo presenteados com uma bela saudação. Incrível! E um teste ponta-a-ponta mais do que suficiente se você nem mesmo possui uma interface Web.
Quanto mais alto você subir em sua pirâmide de testes, maior será a probabilidade de você entrar no campo de testar se os recursos que você está construindo funcionam corretamente do ponto de vista do usuário. Você pode tratar sua aplicação como uma caixa preta e mudar o foco dos seus testes
de:
quando introduzo os valores x e y, o valor de retorno deve ser z
para:
dado que há um usuário logado
e há um artigo "bicicleta"
quando o usuário navega até a página de detalhes do artigo "bicicleta"
e clica no botão "adicionar ao cesto"
então o artigo "bicicleta" deve estar no cesto de compras
Às vezes, você ouvirá os termos teste funcional ou teste de aceitação para esses tipos de testes. Às vezes, as pessoas dirão que os testes funcionais e de aceitação são coisas diferentes. Às vezes, os termos são confundidos. Às vezes, as pessoas discutem interminavelmente sobre palavras e definições. Frequentemente, essa discussão é uma grande fonte de confusão.
O problema é o seguinte: em um ponto, você deve testar se seu software funciona corretamente do ponto de vista do usuário, não apenas do ponto de vista técnico. O que você chama de testes não é tão importante. Ter esses testes, no entanto, é. Escolha um termo, cumpra-o e escreva esses testes.
Este também é o momento em que as pessoas falam sobre BDD (Behaviour-Driven Develpoment) e ferramentas que permitem implementar testes nesse estilo. O BDD ou uma forma de escrever testes no estilo BDD pode ser um bom truque para mudar sua mentalidade dos detalhes de implementação para as necessidades dos usuários. Vá em frente e experimente.
Você nem precisa adotar ferramentas de BDD completas como o Cucumber (embora possa). Algumas bibliotecas de assertions (como chai.js) permitem que você escreva assertions com palavras-chave no estilo-BDD e podem fazer com que seus testes sejam mais parecidos com BDD. E mesmo se você não usar uma biblioteca que forneça essa notação, código inteligente e bem fatorado permitirá que você escreva testes focados no comportamento do usuário. Alguns métodos/funções auxiliares podem ajudá-lo muito:
# Um simples teste de aceitacao em Python
def test_add_to_basket():
# dado
user = a_user_with_empty_basket()
user.login()
bicycle = article(name="bicycle", price=100)
# quando
article_page.add_to_.basket(bicycle)
# então
assert user.basket.contains(bicycle)
Os testes de aceitação podem vir em diferentes níveis de granularidade. Na maioria das vezes, eles serão de alto nível e testarão seu serviço por meio da interface de usuário. No entanto, é bom entender que, tecnicamente, não há necessidade de escrever testes de aceitação no nível mais alto da sua pirâmide de testes. Se o design da sua aplicação e o cenário em questão permitirem que você escreva um teste de aceitação em um nível inferior, vá em frente. Fazer um teste de baixo nível é melhor do que fazer um teste de alto nível. O conceito de testes de aceitação – provar que seus recursos funcionam corretamente para o usuário – é completamente ortogonal à sua pirâmide de testes.
Mesmo os esforços de automação de teste mais diligentes não são perfeitos. Às vezes, você perde certos casos extremos em seus testes automatizados. Às vezes é quase impossível detectar um bug específico escrevendo um teste unitário. Certos problemas de qualidade nem se tornam aparentes em seus testes automatizados (pense em design ou usabilidade). Apesar de suas melhores intenções com relação à automação de teste, alguns tipos de testes manuais ainda são uma boa ideia.
Figura 12. Use testes exploratórios para identificar problemas de qualidade que seu pipeline de construção não detectou
Inclua testes exploratórios em seu portfólio de testes. É uma abordagem de teste manual que enfatiza a liberdade e a criatividade do testador para detectar problemas de qualidade em um sistema em execução. Simplesmente dedique algum tempo em uma programação regular, arregace as mangas e tente quebrar sua aplicação. Use uma mentalidade destrutiva e encontre maneiras de provocar problemas e erros em seu aplicativo. Documente tudo o que encontrar para mais tarde. Fique atento a bugs, problemas de design, tempos de resposta lentos, mensagens de erro ausentes ou enganosas e tudo mais que possa incomodá-lo como usuário de seu software.
A boa notícia é que você pode automatizar a maioria de suas descobertas com testes automatizados. Escrever testes automatizados para os bugs que você detecta garante que não haverá regressão desse bug no futuro. Além disso, ajuda a reduzir a causa raiz desse problema durante a correção de bugs.
Durante o teste exploratório, você detectará problemas que passaram despercebidos pelo pipeline de construção. Não fique frustrado. Este é um ótimo feedback sobre a maturidade do seu pipeline de construção. Como acontece com qualquer feedback, certifique-se de agir de acordo com ele: pense no que você pode fazer para evitar esse tipo de problema no futuro. Talvez você esteja deixando de considerar um determinado conjunto de testes automatizados. Talvez você tenha sido negligente com seus testes automatizados nesta iteração e precise testar mais detalhadamente no futuro. Talvez haja uma nova ferramenta ou abordagem brilhante que você possa usar em seu pipeline para evitar esses problemas no futuro. Certifique-se de agir sobre isso para que seu pipeline e toda a sua entrega de software cresçam mais maduros quanto mais o tempo passa.
Falar sobre diferentes classificações de teste é sempre difícil. O que eu quero dizer quando falo sobre testes de unidade pode ser um pouco diferente do seu entendimento. Com testes de integração é ainda pior. Para algumas pessoas, testes de integração é uma atividade muito ampla que testa várias partes diferentes de um sistema inteiro. Para mim, especificamente, é uma coisa simples, somente testar a integração com uma parte externa por vez. Alguns chamam de testes de integração, outros referem-se como testes de componente, alguns preferem o termo teste de serviço. Alguns ainda irão dizer que todos esses três termos são coisas totalmente diferentes. Não há certo ou errado. A comunidade de desenvolvimento de software simplesmente ainda não conseguiu estabelecer termos bem definidos sobre testes.
Não se preocupe em se prender a termos ambíguos. Não importa se você chama de end-to-end ou broad stack ou teste funcional. Não importa se seus testes de integração significam algo diferente para você do que para o pessoal de outra empresa. Sim, seria muito legal se nossa profissão pudesse estabelecer-se em termos bem definidos e ficar com isso. Infelizmente, isso não aconteceu ainda. E, visto que há muitas nuances quando se fala em escrita de testes, é mais um espectro do que um monte de coisas distintas, o que faz uma nomenclatura consistente ainda mais difícil.
O importante a se aprender é que você encontre termos que funcionem para você e sua equipe. Seja claro sobre os diferentes tipos de testes que deseja escrever. Concorde com a nomenclatura em sua equipe e encontre um consenso sobre o escopo de cada tipo de teste. Se você conseguir isso consistentemente dentro de sua equipe (ou talvez até mesmo dentro de sua organização), isso é tudo com o que você deve se preocupar. Simon Stewart resumiu isso muito bem quando descreveu a abordagem que eles usam no Google. E eu acho que isso mostra perfeitamente como ficar muito preso a nomes e convenções de nomenclatura simplesmente não vale a pena.
Se você está usando Integração Contínua (Continuous Integration) ou Entrega Contínua (Continuous Delivery), você terá um pipeline de implantação em funcionamento que executará testes automatizados sempre que você fizer uma alteração em seu software. Geralmente, este pipeline é dividido em várias etapas que, gradualmente, lhe dão mais confiança de que seu software está pronto para ser implantado em produção. Ao ouvir falar sobre todos esses diferentes tipos de testes, você provavelmente está se perguntando como deve colocá-los em seu pipeline de implantação. Para responder a isso, basta pensar em um dos valores fundamentais da Entrega Contínua (de fato, um dos principais valores da Extreme Programming e também do desenvolvimento ágil de software): rápido feedback.
Um bom pipeline de implantação lhe informa que você errou o mais rápido possível. Você não quer esperar uma hora apenas para descobrir que sua última mudança quebrou alguns testes unitários simples. É provável que você já tenha ido para casa se seu pipeline demorar muito para lhe dar esse feedback. Você poderia obter essa informação em questão de segundos, talvez alguns minutos, colocando os testes de execução rápida nas primeiras etapas de seu pipeline. Consequentemente, você coloca os testes de execução mais longos - geralmente os com escopo mais amplo - nas etapas posteriores para não adiar o feedback dos testes de execução rápida. Percebe, então, que definir as etapas do seu pipeline de implantação não é uma tarefa impulsionada pelos tipos de testes, mas sim pela velocidade e escopo dos testes a serem realizados. Com isso em mente, pode ser uma decisão boa colocar alguns testes de integração com escopos restritos e testes de execução rápida na mesma etapa que seus testes de unidade - simplesmente porque eles lhe dão um feedback mais rápido e não porque você quer traçar a linha ao longo do tipo de seus testes.
Agora que você sabe que deve escrever diferentes tipos de testes, há mais uma armadilha a evitar: duplicar testes em diferentes camadas da pirâmide. Embora seu instinto possa dizer que nunca é demais testar, deixe-me garantir que há um limite. Cada teste em sua suíte de testes é uma carga adicional e não é gratuito. Escrever e manter testes leva tempo. Ler e entender testes de outras pessoas leva tempo. E, é claro, executar testes leva tempo.
Assim como no código de produção, você deve buscar a simplicidade e evitar a duplicação. No contexto da implementação da sua pirâmide de testes, você deve ter em mente duas regras básicas:
-
Se um teste de nível superior identificar um erro e não houver falha em um teste de nível inferior, você precisa escrever um teste de nível inferior.
-
Empurre seus testes o mais para baixo possível na pirâmide de testes.
A primeira regra é importante porque os testes em níveis inferiores permitem que você identifique erros com mais precisão e os replique de forma isolada. Eles serão executados mais rapidamente e serão menos inchados quando você estiver depurando o problema em questão. Eles também servirão como um bom teste de regressão para o futuro. A segunda regra é importante para manter sua suíte de testes rápida. Se você testou todas as condições com confiança em um teste em nível inferior, não há necessidade de manter um teste em nível superior na sua suíte de testes. Isso simplesmente não adiciona mais confiança de que tudo está funcionando. Ter testes redundantes se tornará irritante em seu trabalho diário. Sua suíte de testes será mais lenta e você precisará alterar mais testes quando alterar o comportamento do seu código.
Vamos dizer de outra forma: se um teste em nível superior lhe dá mais confiança de que sua aplicação está funcionando corretamente, você deve tê-lo. Escrever um teste unitário para uma classe Controller ajuda a testar a lógica dentro do Controller em si. No entanto, isso não lhe dirá se o endpoint REST que esse Controller fornece realmente responde a solicitações HTTP. Então você sobe na pirâmide de testes e adiciona um teste que verifica exatamente isso - mas nada mais. Você não testa toda a lógica condicional e casos de borda que seus testes em níveis inferiores já cobrem novamente no teste em nível superior. Certifique-se de que o teste em nível superior se concentra na parte que os testes em níveis inferiores não puderam cobrir.
Eu sou rigoroso quando se trata de eliminar testes que não fornecem nenhum valor. Eu excluo testes em níveis mais altos que já são cobertos em um nível inferior (desde que não forneçam valor adicional). Eu substituo testes em níveis superiores por testes em níveis inferiores, se possível. Às vezes isso é difícil, especialmente se você sabe que criar um teste foi um trabalho árduo. Cuidado com a falácia do custo irrecuperável (sunk cost fallacy) e aperte a tecla delete. Não há razão para desperdiçar mais tempo precioso em um teste que deixou de fornecer valor.
Assim como escrever código em geral, criar um código de teste bom e limpo exige muito cuidado. Aqui estão mais algumas dicas para criar um código de teste de fácil manutenção antes de prosseguir com sua suíte de testes automatizados:
- Código de teste é tão importante quanto código de produção. Dê o mesmo nível de atenção e cuidado para ele. "Isso é apenas código de teste" não é uma desculpa válida para justificar um código desleixado.
- Teste uma condição por teste. Isso vai ajudar a manter os seus testes curtos e fáceis de entender.
- "Arrange, Act, Assert" ou "Given, When, Then" são bons mnemônicos para manter seus testes bem estruturados.
- Legibilidade importa. Não tente ser excessivamente DRY (Don't Repeat Yourself). Duplicação é aceitável, se melhorar a legibilidade. Tente encontrar um equilíbrio entre códigos DRY e DAMP
- Quando estiver com dúvida use a Rule of Three para decidir quando refatorar: use antes de reutilizar.
É isso! Sei que foi uma leitura longa e difícil para explicar porque e como você deve testar seu software. A boa notícia é que essas informações são bastante atemporais e independentes do tipo de software que você está construindo. Não importa se você está trabalhando em um cenário de microsserviços, dispositivos IoT, aplicativos móveis ou aplicativos da Web, as lições deste artigo podem ser aplicadas a todos eles.
Espero que haja algo útil neste artigo. Agora vá em frente e confira o código de amostra e obtenha alguns dos conceitos explicados aqui em seu portfólio de teste. Ter um portfólio de teste sólido exige algum esforço. Vai compensar a longo prazo e vai deixar sua vida como desenvolvedor mais tranquila, acredite
Se você se encontrar em uma situação em que realmente precisa testar um método particular, deve dar um passo para trás e se perguntar "por que?".
Tenho certeza de que isso é mais um problema de design do que um problema de escopo. Muito provavelmente você sente a necessidade de testar um método privado porque é complexo e testar esse método através da interface pública da classe requer muita configuração complicada.
Sempre que me encontro nessa situação, geralmente, chego à conclusão de que a classe que estou testando já é muito complexa. Está fazendo demais e viola o princípio da responsabilidade única - o S dos cinco princípios SOLID.
A solução que geralmente funciona para mim é dividir a classe original em duas classes. Frequentemente, leva apenas um ou dois minutos para pensar e encontrar uma boa maneira de dividir uma grande classe em duas classes menores com responsabilidades individuais. Eu movo o método privado (que quero testar com urgência) para a nova classe e deixo a classe antiga chamar o novo método. Voilà, meu método privado difícil de testar agora é público e pode ser testado facilmente. Além disso, melhorei a estrutura do meu código aderindo ao princípio da responsabilidade única.
É uma coisa linda que você possa escrever testes unitários para todo o seu código, independentemente da camada da arquitetura da sua aplicação em que você esteja. O exemplo deste artigo (ver Implementando um teste de unidade) mostra um simples teste unitário para um controlador. Infelizmente, quando se trata de controladores do Spring, há uma desvantagem nesta abordagem: os controladores do Spring MVC fazem amplo uso de anotações para declarar em quais caminhos eles estão ouvindo, quais verbos HTTP usar, quais parâmetros da URL eles analisam ou quais parâmetros de consulta, e assim por diante. Simplesmente invocar o método de um controlador nos seus testes unitários não testará todas essas coisas cruciais. Felizmente, os desenvolvedores do Spring criaram um bom auxiliar de teste que você pode usar para escrever testes de controladores melhores. Certifique-se de conferir o MockMVC. Ele fornece uma DSL que você pode usar para enviar solicitações falsas ao seu controlador e verificar se está tudo bem. Incluí um exemplo no código de amostra. Muitos frameworks oferecem auxiliares de teste para tornar mais agradável testar aspectos específicos do seu código. Consulte a documentação do seu framework de preferência e veja se ele oferece auxílios úteis para seus testes automatizados.
Footnotes
-
Nota do tradutor: no README do repositório da aplicação de exemplo, o autor menciona ter trocado a API do darksky.net pela do openweathermap.org, depois que a primeira foi desativada para consulta pública à previsão do tempo. ↩