Boas Práticas para Testes Unitários Aninhados
Ao concluirmos esta aula, nós:
- Compreenderemos como eliminar código duplicado de setup e dados de teste em nossas suítes.
- Saberemos como estruturar testes unitários aninhados que são fáceis de ler, escrever e manter.
[!NOTE] Esta aula assume que:
Apresentando as Cinco “Boas Práticas” para Testes Unitários Aninhados
Já sabemos como escrever testes unitários aninhados com @Nested e sabemos que o JUnit invoca os métodos de setup (@BeforeEach) e teardown (@AfterEach) seguindo a hierarquia de classes do método de teste executado. Contudo, ainda precisamos saber como tirar o máximo proveito dessa mecânica no mundo real.
Esta aula apresenta cinco boas práticas fundamentais para estruturar testes unitários aninhados com perfeição.
1. Configure o Sistema Sob Teste no Método de Setup da Classe Raiz
Devemos configurar o Sistema Sob Teste (SUT) no método de setup (@BeforeEach) que fica na classe raiz da nossa hierarquia de classes de teste. Para ser mais específico, esse método de setup na raiz deve fazer apenas duas coisas:
- Criar todas as dependências do Sistema Sob Teste (usando
mockk()oustub()). - Criar uma nova instância da classe testada injetando essas dependências.
Por exemplo, o código-fonte inicial da nossa classe de teste fica assim:
|
|
Como o método anotado com @BeforeEach é executado antes de cada método @Test (em qualquer nível da hierarquia), essa técnica garante que o estado final de um teste unitário concluído jamais interfira na execução do próximo teste unitário, pois cada teste roda contra uma instância nova em folha da classe testada e de seus dublês!
Depois de configurarmos o Sistema Sob Teste, precisamos organizar nossos métodos de teste. Vamos descobrir como criar uma hierarquia de classes que nos ajuda a escrever testes limpos e sem código duplicado.
2. Crie Uma Classe Interna (@Nested) para Cada Método Testado
Depois de configurarmos o Sistema Sob Teste na raiz, a próxima pergunta óbvia é:
Onde devemos colocar nossos métodos de teste?
Devemos seguir estas duas regras:
- Criamos uma
inner classanotada com@Nestedpara cada método público testado do Sistema Sob Teste. - Usamos o nome do método testado (em PascalCase, opcionalmente enriquecido com
@DisplayName) como o nome da classe interna.
Se criarmos uma classe de teste que contém testes para os métodos findById() e save() da classe TaskService, o código-fonte da estrutura fica assim:
|
|
Essa técnica traz três benefícios imediatos:
- Podemos colocar o código de setup específico em seus respectivos contextos (classes internas). Isso torna nossos testes mais fáceis de ler porque criamos um vínculo claro entre o código de setup e os casos de teste daquele método.
- Como todas as IDEs permitem expandir e recolher (collapse) classes internas, podemos abrir apenas a classe interna do método que nos interessa no momento e recolher as demais. Isso reduz drasticamente a carga cognitiva durante a leitura.
- Colocar nossos casos de teste dentro de classes internas aninhadas nos permite eliminar o código duplicado que torna testes tradicionais difíceis de escrever e manter.
Então, devemos colocar nossos métodos @Test diretamente dentro de FindById e Save? A resposta fica evidente ao analisarmos a próxima boa prática!
3. Separe Diferentes Cenários de Teste Usando Classes Internas (When...)
É muito comum precisarmos escrever testes unitários para um mesmo método que exigem configurações ligeiramente diferentes e possuem expectativas distintas para o resultado esperado.
Por exemplo, se estivermos escrevendo testes unitários para um método de serviço findById() que retorna um objeto Task cujo id é passado como parâmetro, precisamos garantir que nosso código funcione como esperado em dois cenários distintos:
- Quando o objeto
Tasknão é encontrado. - Quando o objeto
Taské encontrado.
Antes de escrevermos os métodos @Test propriamente ditos, é uma excelente prática separar esses cenários usando mais um nível de classes internas (@Nested inner class) e nomear essas classes internas adicionando o prefixo When (Quando) à descrição do cenário testado:
- A classe interna
WhenTaskIsNotFoundcontém o código de teste que garante que o métodofindById()funciona como esperado quando a tarefa solicitada não é encontrada. - A classe interna
WhenTaskIsFoundcontém o código de teste que garante que o métodofindById()funciona como esperado quando a tarefa solicitada é encontrada.
Depois de adicionarmos essas duas classes internas dentro da classe FindById, o código-fonte da nossa classe de teste fica assim:
|
|
Além disso, se o método findById() possuir algum comportamento comum que é executado independentemente de a tarefa existir ou não e quisermos testar esse comportamento diretamente, colocamos esse método de teste diretamente na classe FindById.
Essa técnica potencializa os três benefícios anteriores:
- Criamos um vínculo ainda mais forte entre a pré-condição (When…), o código de setup e os casos de teste.
- Nosso código fica muito fácil de navegar como uma árvore de especificações.
- Podemos eliminar toda a duplicação colocando cada código de setup no nível exato da árvore! Vamos ver como identificar esse lugar exato na prática 4.
4. Crie Dados de Teste e Configure Dublês de Teste o Mais Alto Possível na Hierarquia
Se quisermos eliminar o código duplicado, precisamos reutilizar o código que cria nossos dados de teste e configura nossos dublês de teste. A melhor maneira de fazer isso é colocar esse código em um método @BeforeEach no nível mais alto possível da hierarquia onde aquela configuração seja compartilhada por todos os testes abaixo dele.
Vamos analisar dois exemplos que ilustram esse princípio com clareza:
Primeiro Exemplo: Configurações Mutuamente Exclusivas (Nível Mais Baixo)
Quando estamos escrevendo testes unitários para o método findById() da classe TaskService, o cenário WhenTaskIsNotFound exige que o repositório retorne Optional.empty(), enquanto o cenário WhenTaskIsFound exige que o repositório retorne Optional.of(found). Como os dois cenários não compartilham a mesma configuração do stub, colocamos cada @BeforeEach nas classes mais internas:
|
|
Segundo Exemplo: Configuração Compartilhada por Subcenários (Nível Mais Alto)
Agora suponha que estejamos escrevendo testes unitários para um método methodX() que sempre busca um objeto Task existente no banco de dados antes de tomar decisões secundárias nos subcenários WhenA e WhenB.
Nesse caso, devemos criar o objeto Task e configurar o stub TaskRepository para retorná-lo o mais alto possível na hierarquia — isto é, no @BeforeEach da própria classe MethodX!
Veja como fica o código-fonte:
|
|
Essa técnica proporciona dois grandes benefícios:
- Criamos o objeto
Tasknecessário e configuramos o stubTaskRepositoryapenas uma única vez: Os métodos de teste localizados emMethodX,WhenAeWhenBherdam automaticamente essa mesma configuração! Se amanhã fizermos uma alteração na assinatura do métodofindById(), precisaremos atualizar apenas um único ponto. - Nossos testes ficam muito mais fáceis de ler: O código de setup fica dividido em métodos
@BeforeEachpequenos e coesos em cada nível, deixando nossos métodos@Testcurtos e focados exclusivamente na ação (Act) e nas asserções/verificações (Assert).
Avançamos bastante, mas falta falar sobre um aspecto muito importante: onde declarar nossas constantes!
5. Declare Constantes o Mais Baixo Possível na Hierarquia
Quando escrevemos testes unitários tradicionais planos, normalmente colocamos todas as constantes no topo da classe de teste. O problema dessa abordagem é que a classe de teste acaba acumulando uma lista enorme de constantes no topo, e não temos como saber visualmente quais constantes são usadas por quais casos de teste.
Podemos resolver esse problema e vincular nossas constantes diretamente aos casos de teste que as utilizam declarando nossas constantes o mais baixo possível na hierarquia de classes internas!
Por exemplo, ao escrevermos os testes unitários para o método findById() da classe TaskService, declaramos as constantes necessárias seguindo estes dois passos:
- Adicionamos a constante
TASK_ID = 1Lna classeFindById. Declaramos essa constante nesse nível porque ela é compartilhada pelos testes de ambas as classes filhas (WhenTaskIsNotFoundeWhenTaskIsFound). - Adicionamos a constante
TITLE = "Foo"dentro da classeWhenTaskIsFound. Declaramos essa constante no nível mais baixo porque ela é usada exclusivamente pelos testes dentro deWhenTaskIsFound!
O código-fonte da nossa classe de teste fica assim:
|
|
Como podemos ver, nossa classe de teste fica limpa e autoexplicativa porque enxergamos imediatamente que:
TASK_IDpertence ao contexto deFindById(usado tanto emWhenTaskIsNotFoundquanto emWhenTaskIsFound).TITLEpertence exclusivamente ao contexto deWhenTaskIsFound.
Isoladamente pode parecer um detalhe sutil, mas quando combinamos essa prática com as quatro anteriores, transformamos completamente a legibilidade e a manutenibilidade da nossa suíte de testes!
Resumo
Nesta aula, aprendemos três pilares sobre o design de testes unitários aninhados:
- Tornamos nossos testes muito mais fáceis de ler criando uma hierarquia de classes
@Nestedque separa cada método testado (FindById,Save) e cada cenário de pré-condição (WhenTaskIsNotFound,WhenTaskIsFound). - Eliminamos código duplicado criando dados de teste e configurando dublês de teste o mais alto possível na hierarquia de classes onde aquela configuração é compartilhada.
- Vinculamos nossas constantes aos casos de teste que as utilizam declarando-as o mais baixo possível na hierarquia de classes internas.