Escrevendo Testes Unitários Aninhados com @Nested
Ao concluirmos esta aula, nós:
- Compreenderemos por que devemos escrever testes unitários aninhados (nested unit tests) para eliminar código duplicado.
- Saberemos como escrever testes unitários aninhados utilizando a anotação nativa
@Nesteddo JUnit Jupiter 6 cominner classem Kotlin (e como isso substituiu o antigoHierarchicalContextRunnerdo JUnit 4). - Dominaremos a ordem exata de invocação dos métodos de setup (
@BeforeEach), teardown (@AfterEach) e teste (@Test) ao longo da hierarquia de classes aninhadas.
[!NOTE] Esta aula assume que:
- Você está familiarizado com classes de teste e ciclo de vida no JUnit 6.
- Você conhece a arquitetura do JUnit Platform.
- Você sabe como executar testes unitários utilizando Maven ou Gradle.
Os Problemas dos Testes Unitários Tradicionais (“Planos”)
Antes de falarmos sobre os problemas dos testes unitários tradicionais, precisamos definir o que é uma classe de teste tradicional (“plana”). Uma classe de teste tradicional cumpre estas condições:
- Ela pode conter métodos de setup (
@BeforeAll,@BeforeEach), métodos de teardown (@AfterEach,@AfterAll) e métodos de teste (@Test). - Todos os métodos são adicionados diretamente na raiz da classe de teste (sem subcontextos).
- Ela pode usar extensões do JUnit (
@ExtendWith).
Uma classe de teste tradicional típica tem a seguinte aparência:
|
|
O maior problema dos chamados testes unitários tradicionais é o código duplicado. Mesmo que criemos nossos dados de teste com Test Data Builders e escrevamos nossas asserções com AssertJ seguindo todas as boas práticas que vimos nas aulas anteriores, ainda assim não conseguiríamos eliminar a duplicação em uma classe de teste plana!
A razão para isso é simples:
- Precisamos criar os dados de teste que são passados como entrada para o Sistema Sob Teste ou retornados pelos métodos stubados.
- Precisamos configurar o comportamento dos dublês de teste (stubs e mocks) que são utilizados pelo Sistema Sob Teste em diferentes cenários (ex.: quando a tarefa é encontrada vs. quando a tarefa não é encontrada).
Uma tentativa ingênua de resolver esse problema em uma classe tradicional seria mover toda a criação de dados de teste e toda a configuração de dublês para o único método @BeforeEach da classe. Contudo, essa “solução” cria três novos problemas graves:
- Nosso único método
@BeforeEachficaria tão gigantesco que seria extremamente difícil de ler e manter. - Nossos métodos
@Testficariam difíceis de ler, escrever e manter porque ficam distantes da configuração relevante e não conseguimos enxergar facilmente quais dados de teste e quais stubs se aplicam a qual teste. - Diferentes cenários de teste frequentemente exigem configurações de stubs incompatíveis entre si (por exemplo, um cenário exige que
findById(1L)retorneOptional.empty()e outro exige quefindById(1L)retorneOptional.of(task)). Em uma classe tradicional plana, isso nos forçaria a criar dezenas de arquivos de classes de teste separados para testar uma única classe de serviço!
Por outro lado, se deixarmos toda a criação de dados e configuração de stubs repetida dentro de cada método @Test, qualquer pequena mudança no Sistema Sob Teste pode quebrar dezenas de testes unitários e levar horas para consertar linha por linha.
Está claro que, escrevendo testes unitários tradicionais planos, não conseguimos eliminar o código duplicado. Felizmente, podemos resolver esse problema de forma definitiva escrevendo testes unitários aninhados (Nested Unit Tests)!
Escrevendo Testes Unitários Aninhados
Dependências e Configuração: Do HierarchicalContextRunner (JUnit 4) ao @Nested Nativo (JUnit 6)
No JUnit 4, o framework não possuía suporte nativo para classes de teste aninhadas. Por isso, no JUnit 4 era necessário:
- Adicionar uma dependência externa ao
pom.xmloubuild.gradle:de.bechte.junit:junit-hierarchicalcontextrunner:4.12.1. - Anotar a classe de teste raiz com
@RunWith(HierarchicalContextRunner::class).
No JUnit Jupiter 6, o suporte a testes unitários aninhados já vem integrado nativamente no núcleo do JUnit (junit-jupiter-api) através da anotação @Nested!
- Não precisamos adicionar nenhuma biblioteca de terceiros no Maven ou no Gradle.
- Não precisamos configurar nenhum
@RunWithna classe raiz. - Basta declarar uma
inner classem Kotlin e anotá-la com@Nested!
[!IMPORTANT] Por que devemos usar
inner classem Kotlin com@Nested? Em Java, uma classe interna declarada sem a palavrastatic(public class ContextA) já é automaticamente uma inner class (que possui acesso aos campos e propriedades da classe externa).Já em Kotlin, uma
class ContextAdeclarada dentro de outra classe sem modificador é compilada como uma classe estática (static class), que não tem acesso às propriedades da classe pai! Portanto, em Kotlin, sempre declaramos nossas classes de teste aninhadas como@Nested inner class ContextApara que elas possam acessar o Sistema Sob Teste e os dublês declarados na classe pai.
Construindo Nossa Primeira Hierarquia de Testes Aninhados Passo a Passo
Vamos criar uma classe de teste simples chamada NestedTest e adicionar algumas classes internas aninhadas dentro dela. O objetivo deste exercício é demonstrar na prática a ordem exata de invocação dos métodos de setup, teardown e teste em cada nível da hierarquia.
Passo 1: Criar a classe raiz NestedTest
Primeiro, criamos a classe de teste raiz NestedTest, adicionamos todos os métodos de setup (@BeforeAll, @BeforeEach) e teardown (@AfterEach, @AfterAll) e um método de teste na raiz (rootClassTest()).
O código-fonte inicial da nossa classe de teste fica assim:
|
|
Passo 2: Adicionar a classe interna ContextA
Segundo, adicionamos uma nova inner class chamada ContextA anotada com @Nested dentro da classe NestedTest. Dentro de ContextA, adicionamos um método de setup (@BeforeEach), um método de teardown (@AfterEach) e um método de teste (contextATest()).
Após adicionarmos ContextA à classe NestedTest, o código-fonte fica assim:
|
|
Passo 3: Adicionar a classe interna ContextC dentro de ContextA
Terceiro, adicionamos uma nova inner class chamada ContextC anotada com @Nested dentro da classe ContextA (um terceiro nível de profundidade!). Dentro de ContextC, adicionamos também um método @BeforeEach, um método @AfterEach e um método @Test (contextCTest()).
Após adicionarmos ContextC dentro de ContextA, o código-fonte completo da nossa classe NestedTest fica assim:
|
|
Analisando a Saída e a Ordem de Execução da Hierarquia
Quando executamos nossos testes unitários, vemos a seguinte saída no console:
|
|
Essa saída revela com perfeição a ordem de invocação dos métodos de setup, teardown e teste! Vamos percorrer cada um dos três métodos de teste passo a passo:
-
Primeiro, quando o JUnit executa o método
rootClassTest(), ele invoca os métodos na seguinte ordem:beforeAllTestMethods()(uma única vez no início da suíte)beforeEachTestMethod()rootClassTest()afterEachTestMethod()afterAllTestMethods()(ao final de toda a classe)
-
Segundo, quando o JUnit executa o método
contextATest(), ele invoca os métodos na seguinte ordem (de cima para baixo no@BeforeEach, e de baixo para cima no@AfterEach):beforeAllTestMethods()beforeEachTestMethod()(da classe raiz)beforeEachTestMethodOfContextA()(do ContextA)contextATest()afterEachTestMethodOfContextA()(do ContextA)afterEachTestMethod()(da classe raiz)afterAllTestMethods()
-
Terceiro, quando o JUnit executa o método
contextCTest(), ele invoca os métodos na seguinte ordem:beforeAllTestMethods()beforeEachTestMethod()(da classe raiz)beforeEachTestMethodOfContextA()(do ContextA)beforeEachTestMethodOfContextC()(do ContextC)contextCTest()afterEachTestMethodOfContextC()(do ContextC)afterEachTestMethodOfContextA()(do ContextA)afterEachTestMethod()(da classe raiz)afterAllTestMethods()
Em outras palavras: o JUnit invoca os métodos de setup (@BeforeEach) de fora para dentro (do ancestral mais alto até a classe interna do teste) e os métodos de teardown (@AfterEach) de dentro para fora, seguindo exatamente a hierarquia de contextos do método de teste invocado!
Isso significa que podemos eliminar toda a duplicação de código de setup posicionando cada parte da configuração no nível exato da hierarquia em que ela é compartilhada! Aprenderemos exatamente como aplicar isso na próxima aula deste tópico.
[!NOTE] As extensões do JUnit 6 (
@ExtendWith,@TempDiretc.) também funcionam perfeitamente dentro de classes@Nestede respeitam a mesma hierarquia de escopo.
Resumo
Nesta aula, aprendemos três pontos fundamentais:
- O código duplicado de configuração é o maior problema dos testes unitários tradicionais “planos”.
- Para escrever testes unitários aninhados no JUnit 6 com Kotlin, declaramos classes internas com
@Nested inner class(que substituiu de forma nativa o antigo runner externoHierarchicalContextRunnerdo JUnit 4). - O JUnit invoca os métodos
@BeforeEache@AfterEachseguindo rigorosamente a hierarquia de contextos do método de teste executado.