Introdução às Classes de Teste no JUnit 6 e Ciclo de Vida

Ao concluirmos esta aula, nós:

  • Saberemos como criar novas classes de teste no JUnit Jupiter 6 com Kotlin.
  • Compreenderemos como utilizar métodos de configuração (setup) e limpeza (teardown).
  • Seremos capazes de adicionar métodos de teste a uma classe de teste e validar sua ordem exata de execução.

Criando uma Nova Classe de Teste

Podemos criar uma nova classe de teste simplesmente declarando uma classe em Kotlin. Embora não exista uma convenção de nomenclatura obrigatória imposta pelo compilador, a convenção mais adotada pela comunidade e esperada por ferramentas de build (como o Gradle 9) para nomear uma classe de teste unitário é adicionar o sufixo Test ao nome da classe ou unidade testada.

Como nesta primeira aula introdutória ainda não estamos escrevendo testes unitários para uma classe de domínio específica, criaremos uma classe de teste chamada SetUpAndTearDownTest. Seu código-fonte inicial é o seguinte:

1
2
3
class SetUpAndTearDownTest {

}
Nota sobre Visibilidade (JUnit 4 vs JUnit Jupiter 6 e Kotlin): No JUnit 4 clássico com Java, todas as classes de teste eram obrigadas a ter o modificador explícito public. No JUnit Jupiter 6, essa exigência foi removida (classes podem ser public, internal ou package-private). Além disso, em Kotlin, a visibilidade padrão já é public, portanto nunca precisamos poluir a declaração da classe com a palavra-chave public.

A seguir, descobriremos como podemos adicionar métodos de teste à nossa classe de teste.


Adicionando Métodos de Teste a uma Classe de Teste

Podemos adicionar um novo método de teste a uma classe de teste seguindo dois passos simples:

  1. Adicionar uma função (fun) à classe de teste e garantir que a função não retorne nenhum valor (ou seja, que seu tipo de retorno seja Unit, o padrão implícito do Kotlin).
  2. Anotar a função com a anotação @Test do pacote org.junit.jupiter.api.Test. Essa anotação informa ao JUnit Jupiter que a função em questão pode e deve ser executada como um caso de teste.
Nota: O JUnit Jupiter exige que métodos anotados com @Test não retornem valores (Unit / void) e não sejam privados (private). Em Kotlin, podemos inclusive usar nomes descritivos entre crases (backticks), como fun `meu teste descritivo`(), ou nomes tradicionais em camelCase.

Vamos adicionar dois métodos de teste à nossa classe de teste:

  • O método testOne() simplesmente imprime a String 'Test One' na saída padrão (println).
  • O método testTwo() simplesmente imprime a String 'Test Two' na saída padrão (println).

Depois de adicionarmos esses dois métodos de teste à nossa classe, seu código-fonte ficará assim:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import org.junit.jupiter.api.Test

class SetUpAndTearDownTest {

    @Test
    fun testOne() {
        println("Test One")
    }

    @Test
    fun testTwo() {
        println("Test Two")
    }
}
Atenção — Problemas Encontrados Nesta Classe Inicial:

Antes de continuarmos, quero alertá-lo sobre três problemas presentes nesta classe de demonstração:

Primeiro, os nomes dos métodos de teste (testOne e testTwo) não descrevem o resultado esperado nem o comportamento testado. Isso significa que, se um método de teste falhar em um projeto real, teremos que abrir e ler o código-fonte do método antes de descobrir qual é o problema. Isso é um enorme desperdício de tempo.

Segundo, nossos métodos de teste não possuem nenhuma asserção (assertion). Em outras palavras, esses métodos de teste nunca podem falhar (a menos que lancem uma exceção inesperada).

Terceiro, nossos métodos de teste escrevem informações no console (println / System.out). Não há problema em usar println temporariamente para fins de depuração local (embora o uso de um debugger seja preferível), mas jamais devemos commitar testes que escrevem no console em nosso sistema de controle de versão (Git), pois esses testes poluem a saída da suíte de testes e dificultam a leitura dos logs no CI/CD.

Utilizo essa abordagem aqui exclusivamente porque quero demonstrar de forma visual quando e em que ordem cada método do ciclo de vida é executado pelo JUnit, e imprimir linhas no console é a maneira mais simples de observar isso.

Acabamos de escrever nossos primeiros métodos de teste. Este é um bom começo. No entanto, quando escrevemos testes unitários para uma aplicação do mundo real, precisamos garantir que o sistema sob teste (System Under Test - SUT) seja colocado em um estado conhecido e isolado antes que nossos métodos de teste sejam executados. Fazemos isso utilizando métodos de configuração (setup methods).


Utilizando Métodos de Configuração (Setup Methods)

O JUnit Jupiter oferece dois tipos diferentes de métodos de configuração, descritos a seguir:

Primeiro, o JUnit possui um método de setup que é invocado antes de cada método de teste. Esse método é tipicamente utilizado para criar e configurar o sistema sob teste (SUT). Isso significa que:

  • Devemos instanciar as dependências do objeto testado dentro deste método. Em outras palavras, se quisermos usar dublês de teste (test doubles, como mocks ou stubs) em nossos testes unitários, devemos criá-los ou reiniciá-los aqui.
  • Devemos criar a instância do próprio objeto testado neste método.

Podemos criar esse método de configuração seguindo estes passos:

  1. Adicionar uma função à classe de teste e garantir que ela não retorne nenhum valor (Unit).
  2. Anotar a função com a anotação @BeforeEach (que substituiu a antiga anotação @Before do JUnit 4). Essa anotação informa ao JUnit que a função em questão deve ser executada antes de cada função anotada com @Test.
Nota: No JUnit Jupiter, o método anotado com @BeforeEach não deve retornar nenhum valor e não pode ser private. Diferente do JUnit 4, o JUnit Jupiter permite injeção de parâmetros em métodos @BeforeEach caso você utilize extensões (como TestInfo ou @TempDir).

Vamos adicionar um novo método de configuração à nossa classe de teste. Este método imprime a string 'Invoked before each test method' no console.

Depois de adicionarmos este método à nossa classe de teste, seu código-fonte ficará assim:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test

class SetUpAndTearDownTest {

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

    @Test
    fun testOne() {
        println("Test One")
    }

    @Test
    fun testTwo() {
        println("Test Two")
    }
}

Segundo, o JUnit possui um método de setup que é invocado uma única vez antes que qualquer método de teste da classe seja executado. Esse método é tipicamente usado para realizar configurações pesadas ou intensivas em recursos que são compartilhadas por vários métodos de teste. Por exemplo, se precisarmos iniciar um servidor em memória ou abrir uma conexão compartilhada, devemos fazer isso neste método.

Podemos criar esse método de configuração único seguindo estes passos:

  1. Adicionar um método estático (em Kotlin, dentro de um companion object anotado com @JvmStatic) à classe de teste, garantindo que ele não retorne nada (Unit). (Alternativamente, no JUnit Jupiter, você pode anotar a classe com @TestInstance(TestInstance.Lifecycle.PER_CLASS) para usar métodos normais sem precisar de companion object).
  2. Anotar o método com a anotação @BeforeAll (que substituiu a antiga @BeforeClass do JUnit 4). Essa anotação instrui o JUnit a invocar este método de setup uma única vez antes da execução de todos os métodos de teste da classe.
Nota: Por padrão (ciclo de vida PER_METHOD), o JUnit Jupiter exige que o método anotado com @BeforeAll seja estático (@JvmStatic na JVM) e não retorne nenhum valor.

Vamos adicionar esse segundo método de configuração à nossa classe de teste. Este método imprime a string 'Invoked once before all test methods' no console.

Depois de adicionarmos este método à nossa classe de teste, seu código-fonte terá 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
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test

class SetUpAndTearDownTest {

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

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

    @Test
    fun testOne() {
        println("Test One")
    }

    @Test
    fun testTwo() {
        println("Test Two")
    }
}

Agora já sabemos como utilizar os métodos de configuração suportados pelo JUnit Jupiter. Às vezes, reservamos recursos externos em nossos métodos de setup. Quando fazemos isso, naturalmente precisamos liberar esses recursos após a execução. Fazemos isso utilizando métodos de limpeza (teardown methods).


Utilizando Métodos de Limpeza (Teardown Methods)

O JUnit Jupiter oferece dois tipos diferentes de métodos de limpeza, descritos a seguir:

Primeiro, o JUnit possui um método de teardown que é invocado após cada método de teste. Podemos utilizar esse método quando precisamos liberar recursos externos ou limpar estados que foram alocados no método anotado com @BeforeEach.

Podemos criar esse método de limpeza seguindo estes passos:

  1. Adicionar uma função à nossa classe de teste e garantir que ela não retorne nenhum valor (Unit).
  2. Anotar a função com a anotação @AfterEach (que substituiu a antiga @After do JUnit 4). Essa anotação informa ao JUnit que a função em questão deve ser executada após cada função anotada com @Test.
Nota: O JUnit Jupiter exige que o método anotado com @AfterEach não seja privado e não retorne nenhum valor (Unit). Ele é executado mesmo que o método @Test tenha falhado ou lançado uma exceção.

Vamos adicionar um novo método de limpeza à nossa classe de teste. Este método imprime a string 'Invoked after each test method' no console.

Depois de adicionarmos este método à nossa classe de teste, seu código-fonte ficará 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
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test

class SetUpAndTearDownTest {

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

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

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

    @Test
    fun testOne() {
        println("Test One")
    }

    @Test
    fun testTwo() {
        println("Test Two")
    }
}

Segundo, o JUnit possui um método de teardown que é invocado uma única vez após todos os métodos de teste terem sido executados. Podemos usar esse método quando queremos liberar recursos externos compartilhados que foram reservados no método anotado com @BeforeAll.

Podemos criar esse método de limpeza final seguindo estes passos:

  1. Adicionar um método estático (@JvmStatic dentro do companion object) à classe de teste e garantir que o método não retorne nenhum valor (Unit).
  2. Anotar o método com a anotação @AfterAll (que substituiu a antiga @AfterClass do JUnit 4). Essa anotação informa ao JUnit que este método de limpeza deve ser invocado uma única vez depois que todos os métodos de teste da classe tiverem terminado.
Nota: Assim como no @BeforeAll, no modo de ciclo de vida padrão o método anotado com @AfterAll deve ser estático (@JvmStatic) e não retornar nenhum valor.

Vamos adicionar esse segundo método de limpeza à nossa classe de teste. Este método imprime a string 'Invoked once after all test methods' no console.

Depois de adicionarmos este método à nossa classe de teste, seu código-fonte completo sincronizado com a nossa suíte de testes é o seguinte:

SetUpAndTearDownTest.kt
Arquivo Real KOTLIN
 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
package com.testwithspring.starter.lifecycle

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.Test

class SetUpAndTearDownTest {

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

        @AfterAll
        @JvmStatic
        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 testOne() {
        println("Test One")
    }

    @Test
    fun testTwo() {
        println("Test Two")
    }
}

Agora já sabemos como adicionar métodos de teste à nossa classe e como utilizar todos os métodos de setup e teardown suportados pelo JUnit Jupiter.

Resta apenas uma pergunta importante:

O JUnit realmente executa os métodos encontrados em nossa classe de teste na ordem descrita?

Vamos avançar e descobrir o que acontece quando executamos nossos testes unitários.


Executando Nossos Testes Unitários

Quando executamos nossa classe de teste SetUpAndTearDownTest (via IDE ou rodando ./gradlew test --tests SetUpAndTearDownTest --info), capturamos a evidência real de execução detalhada gerada pelo Gradle Test Executor:

Evidência Real de Execução (Gradle --info)
Terminal Log
Initialized native services in: /home/wc/.gradle/native
Initialized jansi services in: /home/wc/.gradle/native
The client will now receive all logging from the daemon (pid: 15302, address: [fe80::1%1:54325]).
Gradle Daemon started in 1 s 105 ms
Starting Build
Settings evaluated using settings file '/home/wc/test-with-spring/code/01-starter-junit6/settings.gradle.kts'.
Projects loaded. Root project using build file '/home/wc/test-with-spring/code/01-starter-junit6/build.gradle.kts'.
Included projects: [root project 'starter-junit6']

> Configure project :
Evaluating root project 'starter-junit6' using build file '/home/wc/test-with-spring/code/01-starter-junit6/build.gradle.kts'.
Using Kotlin Gradle Plugin 2.4.21
Using Java Toolchain: Temurin 21.0.6 (/home/wc/.sdkman/candidates/java/21.0.6-tem)
Spring Boot 4.1.2 plugin applied.
All projects evaluated.
Selected primary task 'test' from project :
Tasks to be executed: [task ':compileKotlin', task ':compileJava', task ':processResources', task ':classes', task ':compileTestKotlin', task ':compileTestJava', task ':processTestResources', task ':testClasses', task ':test']
Tasks that were skipped: [task ':compileJava', task ':compileTestJava']

> Task :compileKotlin UP-TO-DATE
> Task :processResources NO-SOURCE
> Task :classes UP-TO-DATE
> Task :compileTestKotlin
Compiling with Kotlin compiler version 2.4.21 (JVM target 21)
Compiling Kotlin files from [/home/wc/test-with-spring/code/01-starter-junit6/src/test/kotlin]

> Task :processTestResources NO-SOURCE
> Task :testClasses
> Task :test
Starting process 'Gradle Test Executor 1'. Working directory: /home/wc/test-with-spring/code/01-starter-junit6 Command: /home/wc/.sdkman/candidates/java/21.0.6-tem/bin/java -ea -cp ...
Successfully started process 'Gradle Test Executor 1'

com.testwithspring.starter.lifecycle.SetUpAndTearDownTest > beforeAllTestMethods() STANDARD_OUT
    Invoked once before all test methods

com.testwithspring.starter.lifecycle.SetUpAndTearDownTest > beforeEachTestMethod() STANDARD_OUT
    Invoked before each test method

com.testwithspring.starter.lifecycle.SetUpAndTearDownTest > testOne() STANDARD_OUT
    Test One

com.testwithspring.starter.lifecycle.SetUpAndTearDownTest > afterEachTestMethod() STANDARD_OUT
    Invoked after each test method

com.testwithspring.starter.lifecycle.SetUpAndTearDownTest > testOne() PASSED

com.testwithspring.starter.lifecycle.SetUpAndTearDownTest > beforeEachTestMethod() STANDARD_OUT
    Invoked before each test method

com.testwithspring.starter.lifecycle.SetUpAndTearDownTest > testTwo() STANDARD_OUT
    Test Two

com.testwithspring.starter.lifecycle.SetUpAndTearDownTest > afterEachTestMethod() STANDARD_OUT
    Invoked after each test method

com.testwithspring.starter.lifecycle.SetUpAndTearDownTest > testTwo() PASSED

com.testwithspring.starter.lifecycle.SetUpAndTearDownTest > afterAllTestMethods() STANDARD_OUT
    Invoked once after all test methods

SetUpAndTearDownTest > 2 tests completed, 0 failed, 0 skipped
Finished generating test XML results (0.008 secs) into: /home/wc/test-with-spring/code/01-starter-junit6/build/test-results/test
Generating HTML test report...
Finished generating test html results (0.015 secs) into: /home/wc/test-with-spring/code/01-starter-junit6/build/reports/tests/test

BUILD SUCCESSFUL in 1s
5 actionable tasks: 2 executed, 3 up-to-date

Como podemos comprovar pela saída acima, o JUnit Jupiter invoca os métodos exatamente na ordem previsível e isolada que descrevemos ao longo desta aula. Vamos resumir o que aprendemos.


Resumo

Esta aula nos ensinou quatro pontos fundamentais:

  • No JUnit Jupiter 6 com Kotlin, classes de teste não precisam do modificador public explícito (ao contrário do antigo JUnit 4), e adotam por convenção o sufixo Test.
  • Todos os métodos de teste são anotados com @Test, não podem ser privados e não devem retornar nenhum valor (Unit).
  • O método anotado com @BeforeEach (antigo @Before) é executado antes de cada teste, e o método anotado com @AfterEach (antigo @After) é executado após cada teste.
  • O método anotado com @BeforeAll (antigo @BeforeClass) é executado uma única vez antes de todos os testes da classe, e o método anotado com @AfterAll (antigo @AfterClass) é executado uma única vez após todos os testes; no ciclo de vida padrão, ambos ficam em um companion object com @JvmStatic (ou em métodos regulares caso a classe use @TestInstance(Lifecycle.PER_CLASS)).