Seleção de Casos de Teste 101: Edição Testes Unitários
Ao concluirmos esta aula, nós:
- Compreenderemos por que não devemos tentar minimizar artificialmente o número de casos de teste agrupando verificações desconexas em um único método.
- Saberemos por que a maioria dos nossos testes unitários deve testar apenas uma classe por vez (o mais próximo possível do código testado).
- Compreenderemos por que devemos implementar nossas funcionalidades de cima para baixo (Top-Down) em vez de construí-las de baixo para cima (Bottom-Up).
Três Dicas Essenciais para Selecionar Nossos Casos de Teste
1. Não Tente Minimizar o Número de Casos de Teste
Quando desenvolvedores escrevem seus primeiros testes unitários, é muito comum criarem métodos de teste gigantescos que tentam verificar coisas demais ao mesmo tempo. Por exemplo, um único método de teste tenta simultaneamente:
- Verificar as interações que aconteceram entre o Sistema Sob Teste e um mock.
- Garantir que os objetos passados para os mocks contêm as informações corretas.
- Verificar se cada propriedade do objeto retornado pelo método testado está correta.
Por exemplo, se precisarmos verificar que o método save() da classe TaskService funciona como esperado e tentarmos minimizar o número de métodos @Test escrevendo tudo em um único teste, o código-fonte da nossa classe de teste ficará parecido com isto:
|
|
Esse caso de teste “faz-tudo” possui três problemas sérios:
- Primeiro, é praticamente impossível dar um nome curto e descritivo para ele: Um bom nome de teste deve descrever o resultado esperado, mas não conseguimos fazer isso de forma específica quando o teste verifica uma dezena de coisas diferentes ao mesmo tempo.
- Segundo, nosso caso de teste é difícil de ler e manter: O teste não possui um objetivo único e claro, tornando o método muito mais longo e denso do que deveria ser.
- Terceiro, nosso caso de teste pode falhar por múltiplos motivos diferentes: Um dos principais propósitos dos testes unitários é fornecer diagnóstico imediato sobre a unidade testada quando algo quebra. Como esse teste mistura verificação de mock com diversas asserções sequenciais de retorno, se ele falhar na linha
assertThat(returned.id).isEqualByComparingTo(1L), não faremos ideia se as demais propriedades (title,status,creator) estavam corretas ou não! Teremos que corrigir o código e reexecutar o teste várias vezes até descobrir todos os problemas.
Está claro que não devemos escrever testes abarrotados de asserções desconexas. Algumas pessoas defendem a regra extrema de “apenas uma asserção física por método de teste”, mas uma diretriz muito mais pragmática e eficaz é a formulada por Roy Osherove (autor de The Art of Unit Testing):
“Minha diretriz geralmente é testar um CONCEITO lógico por teste. Você pode ter múltiplas asserções sobre o mesmo objeto se elas representarem o mesmo conceito lógico sendo testado.”
O benefício dessa diretriz é que ela nos ajuda a escrever testes unitários curtos, focados e fáceis de ler, escrever e manter. Além disso, como cada teste verifica apenas um conceito lógico, fica muito fácil escolher um nome claro e preciso para cada método de teste!
Por exemplo, aplicando essa diretriz ao método save() da classe TaskService, dividimos as verificações nos seguintes casos de teste:
- Verificar que o método testado cria e salva uma nova tarefa usando as informações corretas (
shouldCreateNewTaskWithCorrectInformation). - Verificar que a tarefa retornada possui o ID correto (
shouldReturnTaskThatHasCorrectId). - Garantir que o método testado retorna uma tarefa com o título correto (
shouldReturnTaskThatHasCorrectTitle). - Garantir que a tarefa retornada possui a descrição correta (
shouldReturnTaskThatHasCorrectDescription). - Verificar que a tarefa retornada não possui responsável atribuído (
shouldReturnTaskThatHasNoAssignee). - Verificar que o criador da tarefa retornada está correto (
shouldReturnTaskThatHasCorrectCreator). - Garantir que o método testado retorna uma tarefa aberta (
shouldReturnOpenTask) — este teste verifica o conceito lógico de “tarefa aberta” (statusTaskStatus.OPEN, semresolutione semcloser).
Depois de refatorarmos nossa classe de teste seguindo essa estrutura, o código-fonte fica assim:
|
|
É evidente que nossa nova classe de teste é muito mais limpa, legível e diagnóstica do que a anterior! E nos poucos métodos que avaliam múltiplas propriedades de um mesmo conceito lógico (como shouldCreateNewTaskWithCorrectInformation e shouldReturnOpenTask), usamos Soft Assertions (ou nossas Asserções Customizadas do Tópico 03) para garantir que todas as propriedades daquele conceito sejam verificadas juntas.
2. Escreva a Maioria dos Testes o Mais Próximo Possível do Código Testado
Um dos temas mais debatidos em testes unitários é a escolha do tamanho da unidade testada. Existem dois grupos vocais com opiniões fortes sobre isso:
- Grupo 1 (Unidade Maior / Caso de Uso): Defende escrever testes unitários focados em um caso de uso completo atravessando múltiplas classes reais (ex.: testar a resolução de uma tarefa invocando o método do
TaskService— ou do controlador — com objetos reais de domínio e substituindo apenas a fronteira externa do banco de dados por um dublê). - Grupo 2 (Unidade Menor / Classe e Método Isolado): Defende escrever testes unitários que testam diretamente o método da classe onde a lógica reside (ex.: testar o método
resolve()diretamente na própria classe de domínioTask).
A diferença mais óbvia entre essas duas estratégias é o tamanho da unidade sob teste:
- Os testes da primeira categoria garantem que múltiplas classes funcionem bem quando colaboram em grupo.
- Os testes da segunda categoria verificam diretamente que os métodos de uma única classe funcionam como esperado.
Antes de mais nada, sejamos absolutamente claros: não existe nenhuma lei que proíba um teste unitário de exercitar mais de uma classe real trabalhando em conjunto (desde que dependências de infraestrutura externa como banco de dados ou rede sejam substituídas por dublês rápidos em memória). Testes de unidade que exercitam um caso de uso são valiosos e rápidos.
No entanto, a maioria dos nossos testes unitários deve testar apenas uma classe por vez, o mais próximo possível de onde a lógica está implementada! Vamos comparar os passos necessários nas duas abordagens para entender o porquê:
- Se quisermos testar toda a lógica de resolução de uma tarefa (
resolve()) apenas através do método doTaskService, precisamos em cada teste:- Criar o objeto
TaskServicetestado e suas dependências (TaskRepository). - Criar o objeto
Taskque será resolvido. - Configurar o stub/mock do repositório para retornar o objeto
Taskcriado quando o serviço chamarfindById(). - Invocar o método do
TaskService. - Verificar o resultado esperado.
- Criar o objeto
- Por outro lado, se testarmos o método
resolve()diretamente na classeTask(onde a regra de transição de estado realmente mora), precisamos apenas:- Criar o objeto
Taskque será resolvido. - Invocar o método
task.resolve(...). - Verificar o resultado esperado!
- Criar o objeto
Esse exemplo simples demonstra por que devemos escrever a maioria dos nossos testes unitários diretamente na classe que detém a regra: esses testes são drasticamente mais simples, exigem menos código de configuração e não precisam de mocks/stubs intermediários! Por exigirem menos código, eles oferecem o melhor retorno sobre o investimento (ROI) e são muito mais fáceis de escrever, ler e manter.
Em resumo: teste todas as ramificações e casos de borda diretamente na classe onde a lógica reside (ex.: Task.resolve()), e escreva no TaskService apenas os testes necessários para comprovar que o serviço coordena a busca, chama o domínio e persiste o resultado!
3. Escreva o Código de Cima para Baixo (Top-Down)
Quando começamos a implementar uma nova funcionalidade (feature), recebemos (ou definimos junto ao cliente/produto) uma lista de requisitos de negócio que descrevem como aquela funcionalidade deve se comportar.
Podemos implementar qualquer funcionalidade seguindo uma destas duas direções:
- De Baixo para Cima (Bottom-Up / “Do alicerce ao telhado”): Criamos primeiro o modelo de dados/tabelas, depois implementamos os repositórios, depois escrevemos as classes de serviço, depois escrevemos os controladores web/REST e, por último, a interface do usuário.
- De Cima para Baixo (Top-Down / Outside-In): Começamos pela fronteira mais externa da funcionalidade (a interface de usuário e os controladores web/REST), depois escrevemos as classes de serviço que o controlador precisa, depois o modelo de domínio rico e, por fim, os repositórios de banco de dados.
À primeira vista, pode parecer que a única diferença entre as duas opções é a ordem cronológica das tarefas. Porém, existe uma diferença profunda na qualidade e seleção dos casos de teste:
- Se implementarmos nossa funcionalidade de baixo para cima (Bottom-Up), é muito difícil conectar nossos casos de teste aos requisitos reais de negócio. Como você está escrevendo um repositório ou entidade antes mesmo de o serviço ou controlador existirem, você acaba escrevendo testes no “piloto automático” para métodos especulativos que talvez nem sejam usados daquela forma, gerando testes desnecessários e desconectados do valor de negócio.
- Se implementarmos nossa funcionalidade de cima para baixo (Top-Down), os requisitos da camada superior definem exatamente o contrato e os requisitos da camada imediatamente abaixo dela!
- Ao implementar e testar o Controlador Web/REST, você descobre exatamente quais métodos e contratos o
TaskServiceprecisa ter. - Ao implementar e testar o
TaskService, você descobre exatamente quais métodos de negócio a entidadeTaske qual contrato oTaskRepositoryprecisam ter.
- Ao implementar e testar o Controlador Web/REST, você descobre exatamente quais métodos e contratos o
Dessa forma, cada caso de teste que você escreve em cada camada pode ser rastreado diretamente até um requisito real de negócio, evitando testes inúteis e mantendo sua suíte enxuta, relevante e fácil de manter!
Resumo
Nesta aula, aprendemos três diretrizes fundamentais para selecionar nossos casos de teste unitário:
- Devemos escrever testes unitários pequenos que testem um único conceito lógico por método de teste.
- Devemos escrever a maioria dos nossos testes unitários o mais próximo possível do código testado (testando regras de negócio diretamente nas classes de domínio/valor), pois esses testes são muito mais simples de ler, escrever e manter.
- Devemos implementar nosso código de cima para baixo (Top-Down), pois isso garante que cada teste escrito reflita uma necessidade real das camadas superiores e possa ser rastreado diretamente até os requisitos de negócio.