Classes de Teste Limpas com Kotlin e JUnit 6

Ao final desta aula, seremos capazes de:

  • Criar classes de teste com Kotlin 2.4.21.
  • Adicionar funções de preparação (setup), limpeza (teardown) e teste a uma classe de teste.
  • Saber como escrever testes aninhados (nested tests) com Kotlin e JUnit Jupiter 6.0.3.
  • Compreender a ordem exata de invocação das funções de setup, teardown e teste em uma hierarquia de contextos.

Vamos começar criando uma classe de teste.

Nota — Esta aula assume que:

  • Você sabe executar testes Kotlin com Maven ou Gradle 9.8.2 (Kotlin DSL).
  • Você conhece os fundamentos de escrita de classes de teste e testes aninhados na plataforma JUnit Jupiter.

Criando uma Classe de Teste

Quando criamos uma nova classe de teste, precisamos criar uma classe Kotlin “normal”. Após criarmos nossa classe de teste, seu código-fonte fica da seguinte forma:

1
2
3
4
5
6
7
8
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Tag

@Tag("unitTest")
@DisplayName("Demonstrates how we can write test classes with Kotlin")
class TestClassExampleTest {

}

Nota — Dois pontos importantes:

  • Embora seja possível adicionar múltiplas classes em um único arquivo Kotlin (.kt), essa não é uma boa ideia em suítes de teste corporativas, pois não queremos construir uma suíte de testes que consista em arquivos longos e confusos.
  • Ao nomear nossas classes de teste, devemos utilizar o sufixo 'Test'.

Em seguida, adicionaremos funções de preparação (setup) e limpeza (teardown) à nossa classe de teste.

Adicionando Funções de Setup e Teardown a uma Classe de Teste

Podemos adicionar funções de setup e teardown à nossa classe de teste seguindo estes passos:

Primeiro, precisamos escrever uma função de setup que seja executada uma única vez antes que qualquer função de teste seja executada. Podemos escrever essa função de setup seguindo estes passos:

  1. Adicione um companion object à nossa classe de teste.
  2. Adicione uma nova função de setup ao companion object e implemente essa função escrevendo uma mensagem no System.out (via println).
  3. Anote a função de setup com a anotação @BeforeAll.
  4. Anote a função de setup com a anotação @JvmStatic. Essa anotação garante que o compilador do Kotlin 2.4.21 gere um método estático no bytecode da classe envolvente (TestClassExampleTest) do objeto, além de um método de instância no próprio companion object.

Após adicionarmos essa função de setup à nossa classe de teste, seu código-fonte fica da seguinte forma:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Tag

@Tag("unitTest")
@DisplayName("Demonstrates how we can write test classes with Kotlin")
class TestClassExampleTest {

    companion object {

        @BeforeAll
        @JvmStatic
        fun beforeAll() {
            println("Before all test functions")
        }
    }
}

Nota: Não é uma boa prática escrever classes de teste reais que imprimam informações no System.out. Utilizamos essa técnica aqui exclusivamente porque queremos demonstrar de forma visual a ordem de invocação das funções de setup, teardown e teste.

Segundo, precisamos escrever uma função de teardown que seja executada uma única vez após todas as funções de teste terem sido executadas. Podemos escrever essa função de teardown seguindo estes passos:

  1. Adicione uma nova função de teardown ao companion object e implemente essa função escrevendo uma mensagem no System.out.
  2. Anote a função de teardown com as anotações @AfterAll e @JvmStatic.

Após adicionarmos essa função de teardown à nossa classe de teste, seu código-fonte fica da seguinte forma:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Tag

@Tag("unitTest")
@DisplayName("Demonstrates how we can write test classes with Kotlin")
class TestClassExampleTest {

    companion object {

        @BeforeAll
        @JvmStatic
        fun beforeAll() {
            println("Before all test functions")
        }

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

Nota — Ciclo de Vida de Instância no JUnit Jupiter 6.0.3:

Precisamos usar métodos estáticos (companion object + @JvmStatic) porque estamos utilizando a configuração padrão de ciclo de vida do JUnit Jupiter 6.0.3 (TestInstance.Lifecycle.PER_METHOD). Isso significa que o JUnit 6 cria um novo objeto de teste para cada método de teste executado. Se configurarmos o JUnit 6 para criar apenas um objeto de teste por classe de teste (via @TestInstance(TestInstance.Lifecycle.PER_CLASS) ou globalmente no arquivo junit-platform.properties), não precisamos usar companion object nem @JvmStatic ao escrever essas funções @BeforeAll e @AfterAll.

Leitura Adicional:

Terceiro, precisamos escrever uma função de setup que seja invocada antes da execução de cada função de teste. Podemos escrever essa função de setup seguindo estes passos:

  1. Adicione uma nova função de setup à nossa classe de teste e implemente essa função escrevendo uma mensagem no System.out.
  2. Anote a função de setup com a anotação @BeforeEach.

Após adicionarmos essa função de setup à nossa classe de teste, seu código-fonte fica da seguinte forma:

 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
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeAll

@Tag("unitTest")
@DisplayName("Demonstrates how we can write test classes with Kotlin")
class TestClassExampleTest {

    companion object {

        @BeforeAll
        @JvmStatic
        fun beforeAll() {
            println("Before all test functions")
        }

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

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

Quarto, precisamos escrever uma função de teardown que seja invocada após a execução de cada função de teste. Podemos escrever essa função de teardown seguindo estes passos:

  1. Adicione uma nova função de teardown à nossa classe de teste e implemente essa função escrevendo uma mensagem no System.out.
  2. Anote a função de teardown com a anotação @AfterEach.

Após adicionarmos essa função de teardown à nossa classe de teste, seu código-fonte fica da seguinte forma:

 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
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeAll

@Tag("unitTest")
@DisplayName("Demonstrates how we can write test classes with Kotlin")
class TestClassExampleTest {

    companion object {

        @BeforeAll
        @JvmStatic
        fun beforeAll() {
            println("Before all test functions")
        }

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

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

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

Agora adicionamos todas as funções de setup e teardown à nossa classe de teste. Vamos avançar e descobrir como podemos escrever testes aninhados com Kotlin 2.4.21 e JUnit Jupiter 6.0.3.

Escrevendo Testes Aninhados (Nested Tests)

Quando adicionamos testes aninhados à nossa classe de teste em Kotlin, precisamos utilizar classes internas (inner class). Diferente do Java (onde uma classe interna sem static já é uma inner class por padrão), em Kotlin uma classe declarada dentro de outra é estática por padrão, exigindo explicitamente o modificador inner para que o JUnit Jupiter consiga instanciá-la vinculada à instância da classe externa.

Vamos adicionar algumas classes internas à nossa classe de teste. A ideia deste exercício é demonstrar a ordem de invocação das funções de setup, teardown e teste.

Podemos adicionar as classes internas necessárias à nossa classe de teste seguindo estes passos:

Primeiro, precisamos adicionar uma nova inner class chamada A à nossa classe de teste e anotar a classe interna com a anotação @Nested. Após criarmos a classe A, devemos adicionar uma função de setup, uma de teardown e uma função de teste à classe interna criada.

 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
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Nested

@Tag("unitTest")
@DisplayName("Demonstrates how we can write test classes with Kotlin")
class TestClassExampleTest {

    companion object {

        @BeforeAll
        @JvmStatic
        fun beforeAll() {
            println("Before all test functions")
        }

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

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

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

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

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

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

        @Test
        @DisplayName("Example test for function A")
        fun testForFunctionA() {
            println("Example test for function A")
        }
    }
}

Segundo, precisamos adicionar uma nova inner class chamada WhenX dentro da classe A e anotar a classe interna com a anotação @Nested. Após criarmos a classe WhenX, devemos adicionar uma função de setup, uma de teardown e uma função de teste à classe interna criada.

Após adicionarmos essa classe interna à classe A, o código-fonte da nossa classe de teste fica da seguinte forma:

 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
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Nested

@Tag("unitTest")
@DisplayName("Demonstrates how we can write test classes with Kotlin")
class TestClassExampleTest {

    companion object {

        @BeforeAll
        @JvmStatic
        fun beforeAll() {
            println("Before all test functions")
        }

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

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

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

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

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

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

        @Test
        @DisplayName("Example test for function A")
        fun testForFunctionA() {
            println("Example test for function A")
        }

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

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

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

            @Test
            @DisplayName("Example test for function A when X is true")
            fun testForFunctionAWhenX() {
                println("Example test for function A when X is true")
            }
        }
    }
}

Agora adicionamos testes aninhados à nossa classe de teste. Em seguida, descobriremos o que acontece quando executamos nossos testes.

Executando Nossos Testes

Quando executamos nossos testes (por exemplo, via ./gradlew test no Gradle 9.8.2), devemos ver a seguinte saída:

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

Em outras palavras, o JUnit Jupiter 6.0.3 invoca as funções de setup e teardown seguindo a hierarquia de contexto das funções de teste invocadas (de fora para dentro no @BeforeEach, e de dentro para fora no @AfterEach). Isso significa que podemos eliminar código duplicado de preparação colocando nosso código exatamente no nível correto da hierarquia.

Escrevemos agora uma classe de teste que contém funções aninhadas de setup, teardown e teste, além de compreendermos a ordem de invocação dessas funções. Vamos resumir o que aprendemos nesta aula.

Resumo

Esta aula nos ensinou três pontos fundamentais:

  • Não devemos adicionar múltiplas classes de teste de topo em um único arquivo Kotlin, pois isso cria uma suíte de testes composta por arquivos longos e difíceis de manter.
  • Se utilizarmos a configuração padrão de ciclo de vida do JUnit Jupiter 6.0.3 (PER_METHOD) e precisarmos escrever funções de setup e teardown que são executadas apenas uma vez (@BeforeAll e @AfterAll), devemos adicionar essas funções a um companion object e anotá-las com @JvmStatic.
  • Quando adicionamos testes aninhados (@Nested) à nossa classe de teste em Kotlin, devemos utilizar classes internas marcadas com a palavra-chave inner class.