Escrevendo Testes Hierárquicos com @Nested no JUnit Jupiter

Depois de concluirmos esta aula bônus, nós:

  • Saberemos como escrever testes aninhados (nested tests) no JUnit Jupiter utilizando a anotação @Nested.
  • Compreenderemos como os métodos @BeforeEach e @AfterEach funcionam e em qual ordem são invocados ao longo da hierarquia de classes internas.

1. Introdução à Nossa Classe de Teste Base

Na aula anterior, vimos como configurar métodos de setup e teardown em uma classe de teste plana. Durante esta aula, partiremos daquela mesma classe de teste base (JUnit5NestedExampleTest) e adicionaremos classes internas aninhadas com seus próprios métodos de setup, teardown e teste para observar o comportamento da hierarquia.

O código inicial da nossa classe de teste é o seguinte:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package com.testwithspring.intermediate.junit5

import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName

@DisplayName("JUnit 5 Nested Example")
internal class JUnit5NestedExampleTest {

    companion object {
        @BeforeAll
        @JvmStatic
        fun beforeAll() {
            println("Before all test methods")
        }

        @AfterAll
        @JvmStatic
        fun afterAll() {
            println("After all test methods")
        }
    }

    @BeforeEach
    fun beforeEach() {
        println("Before each test method")
    }

    @AfterEach
    fun afterEach() {
        println("After each test method")
    }
}

Vamos descobrir agora como adicionar classes de teste aninhadas a essa estrutura!


2. Escrevendo Testes Aninhados com @Nested

Vamos adicionar duas classes internas em níveis diferentes da nossa classe JUnit5NestedExampleTest para demonstrar a ordem de invocação dos métodos de setup, teardown e teste:

Passo 1: Adicionar a Primeira Classe Interna (inner class A)

Primeiro, adicionamos uma classe interna não-estática (inner class em Kotlin) chamada A dentro da nossa classe de teste raiz e a anotamos com @Nested. Essa anotação garante que o JUnit Jupiter reconheça e execute os testes dentro da classe interna!

Depois de criarmos a classe A, adicionamos um método @BeforeEach, um método @AfterEach e um método @Test dentro dela:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
package com.testwithspring.intermediate.junit5

import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test

@DisplayName("JUnit 5 Nested Example")
internal class JUnit5NestedExampleTest {

    companion object {
        @BeforeAll
        @JvmStatic
        fun beforeAll() {
            println("Before all test methods")
        }

        @AfterAll
        @JvmStatic
        fun afterAll() {
            println("After all test methods")
        }
    }

    @BeforeEach
    fun beforeEach() {
        println("Before each test method")
    }

    @AfterEach
    fun afterEach() {
        println("After each test method")
    }

    @Nested
    @DisplayName("Tests for the method A")
    inner class A {

        @BeforeEach
        fun beforeEach() {
            println("Before each test method of the A class")
        }

        @AfterEach
        fun afterEach() {
            println("After each test method of the A class")
        }

        @Test
        @DisplayName("Sample test for method A")
        fun sampleTestForMethodA() {
            println("Sample test for method A")
        }
    }
}

Três Detalhes Importantes sobre @Nested:

  1. Classes internas não-estáticas (inner class no Kotlin): Em Kotlin, uma classe declarada dentro de outra é static por padrão. Para que o JUnit Jupiter consiga acessar o estado e os métodos @BeforeEach da classe externa (e reconhecer o @Nested), você deve declarar a classe aninhada com a palavra-chave inner class.
  2. Profundidade ilimitada: Não existe nenhum limite para a profundidade da hierarquia de classes @Nested.
  3. Uso de @BeforeAll e @AfterAll em classes @Nested: Por padrão (quando o ciclo de vida da instância é PER_METHOD), métodos @BeforeAll e @AfterAll não são usados em classes internas porque a JVM não permite membros estáticos em inner classes tradicionais sem @TestInstance(Lifecycle.PER_CLASS).

Passo 2: Adicionar um Segundo Nível de Aninhamento (inner class WhenX)

Segundo, adicionamos uma nova classe interna chamada WhenX dentro da classe A, anotando-a também com @Nested e @DisplayName("When X is true"). Dentro de WhenX, adicionamos seu próprio método @BeforeEach, @AfterEach e @Test:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
package com.testwithspring.intermediate.junit5

import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test

@DisplayName("JUnit 5 Nested Example")
internal class JUnit5NestedExampleTest {

    companion object {
        @BeforeAll
        @JvmStatic
        fun beforeAll() {
            println("Before all test methods")
        }

        @AfterAll
        @JvmStatic
        fun afterAll() {
            println("After all test methods")
        }
    }

    @BeforeEach
    fun beforeEach() {
        println("Before each test method")
    }

    @AfterEach
    fun afterEach() {
        println("After each test method")
    }

    @Nested
    @DisplayName("Tests for the method A")
    inner class A {

        @BeforeEach
        fun beforeEach() {
            println("Before each test method of the A class")
        }

        @AfterEach
        fun afterEach() {
            println("After each test method of the A class")
        }

        @Test
        @DisplayName("Sample test for method A")
        fun sampleTestForMethodA() {
            println("Sample test for method A")
        }

        @Nested
        @DisplayName("When X is true")
        inner class WhenX {

            @BeforeEach
            fun beforeEach() {
                println("Before each test method of the WhenX class")
            }

            @AfterEach
            fun afterEach() {
                println("After each test method of the WhenX class")
            }

            @Test
            @DisplayName("Sample test for method A when X is true")
            fun sampleTestForMethodAWhenX() {
                println("Sample test for method A when X is true")
            }
        }
    }
}

Como Estruturar a Hierarquia no Mundo Real

O segredo para escrever testes hierárquicos limpos, fáceis de ler e fáceis de manter é construir a hierarquia de classes de forma a eliminar código de configuração duplicado:

  • Em Testes Unitários: Uma classe de teste raiz contém todos os casos de teste de uma classe de produção (ex: TodoCrudServiceTest). As classes @Nested do primeiro nível representam cada método testado (ex: FindById). E as classes @Nested do segundo nível representam cada cenário/pré-condição daquele método (ex: WhenTodoItemIsFound e WhenTodoItemIsNotFound).
  • Em Testes de Integração e End-to-End: Uma classe de teste raiz geralmente foca em uma única funcionalidade/endpoint. Isso significa que podemos colocar as classes @Nested que representam os diferentes cenários diretamente no primeiro nível dentro da classe raiz!

3. Executando Nossos Testes Aninhados

Quando executamos a classe JUnit5NestedExampleTest, vemos a seguinte saída no console:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
Before all test methods
Before each test method
Before each test method of the A class
Sample test for method A
After each test method of the A class
After each test method
Before each test method
Before each test method of the A class
Before each test method of the WhenX class
Sample test for method A when X is true
After each test method of the WhenX class
After each test method of the A class
After each test method
After all test methods

Observe a simetria perfeita na execução do teste sampleTestForMethodAWhenX():

  1. Os métodos @BeforeEach são executados de fora para dentro (Raiz -> A -> WhenX).
  2. O método @Test é executado.
  3. Os métodos @AfterEach são executados de dentro para fora (WhenX -> A -> Raiz).

Isso significa que podemos colocar a configuração geral do SUT no @BeforeEach da classe raiz, a configuração comum ao método no @BeforeEach de A e o stub específico do cenário no @BeforeEach de WhenX, eliminando 100% da duplicação de código!


Resumo

Esta aula bônus nos ensinou cinco pontos fundamentais:

  • Todas as classes de teste aninhadas devem ser classes internas não-estáticas (inner class em Kotlin).
  • Todas as classes de teste aninhadas devem ser anotadas com @Nested.
  • A profundidade da hierarquia de classes @Nested não possui limite.
  • Podemos adicionar métodos @BeforeEach e @AfterEach em qualquer nível da hierarquia @Nested (executados de fora para dentro no setup, e de dentro para fora no teardown).
  • Por padrão (sem @TestInstance(Lifecycle.PER_CLASS)), não adicionamos @BeforeAll e @AfterAll em classes @Nested.