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 @Nested do JUnit Jupiter 6 com inner class em Kotlin (e como isso substituiu o antigo HierarchicalContextRunner do 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:


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:

 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
package com.testwithspring.starter.unittests

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.Tag
import org.junit.jupiter.api.Test

@Tag("unitTest")
class TraditionalTest {

    companion object {
        @JvmStatic
        @BeforeAll
        fun beforeAllTestMethods() {
        }

        @JvmStatic
        @AfterAll
        fun afterAllTestMethods() {
        }
    }

    @BeforeEach
    fun beforeEachTestMethod() {
    }

    @AfterEach
    fun afterEachTestMethod() {
    }

    @Test
    fun testOne() {
    }

    @Test
    fun testTwo() {
    }
}

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:

  1. Nosso único método @BeforeEach ficaria tão gigantesco que seria extremamente difícil de ler e manter.
  2. Nossos métodos @Test ficariam 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.
  3. Diferentes cenários de teste frequentemente exigem configurações de stubs incompatíveis entre si (por exemplo, um cenário exige que findById(1L) retorne Optional.empty() e outro exige que findById(1L) retorne Optional.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:

  1. Adicionar uma dependência externa ao pom.xml ou build.gradle: de.bechte.junit:junit-hierarchicalcontextrunner:4.12.1.
  2. 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 @RunWith na classe raiz.
  • Basta declarar uma inner class em Kotlin e anotá-la com @Nested!

[!IMPORTANT] Por que devemos usar inner class em Kotlin com @Nested? Em Java, uma classe interna declarada sem a palavra static (public class ContextA) já é automaticamente uma inner class (que possui acesso aos campos e propriedades da classe externa).

Já em Kotlin, uma class ContextA declarada 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 ContextA para 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:

 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
package com.testwithspring.starter.unittests

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.Tag
import org.junit.jupiter.api.Test

@Tag("unitTest")
class NestedTest {

    companion object {
        @JvmStatic
        @BeforeAll
        fun beforeAllTestMethods() {
            println("Invoked once before all test methods")
        }

        @JvmStatic
        @AfterAll
        fun afterAllTestMethods() {
            println("Invoked once after all test methods")
        }
    }

    @BeforeEach
    fun beforeEachTestMethod() {
        println("Invoked before each test method")
    }

    @AfterEach
    fun afterEachTestMethod() {
        println("Invoked after each test method")
    }

    @Test
    fun rootClassTest() {
        println("Root class test")
    }
}

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:

 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
package com.testwithspring.starter.unittests

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.Nested
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Test

@Tag("unitTest")
class NestedTest {

    companion object {
        @JvmStatic
        @BeforeAll
        fun beforeAllTestMethods() {
            println("Invoked once before all test methods")
        }

        @JvmStatic
        @AfterAll
        fun afterAllTestMethods() {
            println("Invoked once after all test methods")
        }
    }

    @BeforeEach
    fun beforeEachTestMethod() {
        println("Invoked before each test method")
    }

    @AfterEach
    fun afterEachTestMethod() {
        println("Invoked after each test method")
    }

    @Test
    fun rootClassTest() {
        println("Root class test")
    }

    @Nested
    inner class ContextA {

        @BeforeEach
        fun beforeEachTestMethodOfContextA() {
            println("Invoked before each test method of context A")
        }

        @AfterEach
        fun afterEachTestMethodOfContextA() {
            println("Invoked after each test method of context A")
        }

        @Test
        fun contextATest() {
            println("Context A test")
        }
    }
}

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:

 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
80
package com.testwithspring.starter.unittests

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.Nested
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Test

@Tag("unitTest")
class NestedTest {

    companion object {
        @JvmStatic
        @BeforeAll
        fun beforeAllTestMethods() {
            println("Invoked once before all test methods")
        }

        @JvmStatic
        @AfterAll
        fun afterAllTestMethods() {
            println("Invoked once after all test methods")
        }
    }

    @BeforeEach
    fun beforeEachTestMethod() {
        println("Invoked before each test method")
    }

    @AfterEach
    fun afterEachTestMethod() {
        println("Invoked after each test method")
    }

    @Test
    fun rootClassTest() {
        println("Root class test")
    }

    @Nested
    inner class ContextA {

        @BeforeEach
        fun beforeEachTestMethodOfContextA() {
            println("Invoked before each test method of context A")
        }

        @AfterEach
        fun afterEachTestMethodOfContextA() {
            println("Invoked after each test method of context A")
        }

        @Test
        fun contextATest() {
            println("Context A test")
        }

        @Nested
        inner class ContextC {

            @BeforeEach
            fun beforeEachTestMethodOfContextC() {
                println("Invoked before each test method of context C")
            }

            @AfterEach
            fun afterEachTestMethodOfContextC() {
                println("Invoked after each test method of context C")
            }

            @Test
            fun contextCTest() {
                println("Context C test")
            }
        }
    }
}

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:

 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
-------------------------------------------------------
 T E S T S
-------------------------------------------------------
Invoked once before all test methods
Running com.testwithspring.starter.unittests.NestedTest
Invoked before each test method
Root class test
Invoked after each test method

Running com.testwithspring.starter.unittests.NestedTest$ContextA
Invoked before each test method
Invoked before each test method of context A
Context A test
Invoked after each test method of context A
Invoked after each test method

Running com.testwithspring.starter.unittests.NestedTest$ContextA$ContextC
Invoked before each test method
Invoked before each test method of context A
Invoked before each test method of context C
Context C test
Invoked after each test method of context C
Invoked after each test method of context A
Invoked after each test method
Invoked once after all test methods

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:

  1. Primeiro, quando o JUnit executa o método rootClassTest(), ele invoca os métodos na seguinte ordem:

    1. beforeAllTestMethods() (uma única vez no início da suíte)
    2. beforeEachTestMethod()
    3. rootClassTest()
    4. afterEachTestMethod()
    5. afterAllTestMethods() (ao final de toda a classe)
  2. 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):

    1. beforeAllTestMethods()
    2. beforeEachTestMethod() (da classe raiz)
    3. beforeEachTestMethodOfContextA() (do ContextA)
    4. contextATest()
    5. afterEachTestMethodOfContextA() (do ContextA)
    6. afterEachTestMethod() (da classe raiz)
    7. afterAllTestMethods()
  3. Terceiro, quando o JUnit executa o método contextCTest(), ele invoca os métodos na seguinte ordem:

    1. beforeAllTestMethods()
    2. beforeEachTestMethod() (da classe raiz)
    3. beforeEachTestMethodOfContextA() (do ContextA)
    4. beforeEachTestMethodOfContextC() (do ContextC)
    5. contextCTest()
    6. afterEachTestMethodOfContextC() (do ContextC)
    7. afterEachTestMethodOfContextA() (do ContextA)
    8. afterEachTestMethod() (da classe raiz)
    9. 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, @TempDir etc.) também funcionam perfeitamente dentro de classes @Nested e 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 externo HierarchicalContextRunner do JUnit 4).
  • O JUnit invoca os métodos @BeforeEach e @AfterEach seguindo rigorosamente a hierarquia de contextos do método de teste executado.