Introdução à Arquitetura e Ciclo de Vida do JUnit Jupiter (JUnit 5 / 6)

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

  • Saberemos declarar as dependências necessárias da plataforma JUnit (API, Engine, Vintage e Platform Launcher) no Maven e no Gradle.
  • Saberemos executar nossos testes unitários no Maven e no Gradle exibindo a saída padrão no console (showStandardStreams).
  • Estaremos familiarizados com as anotações fundamentais do JUnit Jupiter (@DisplayName, @Test, @BeforeAll, @BeforeEach, @AfterEach e @AfterAll).
  • Compreenderemos a ordem exata em que os métodos de setup, teste e teardown são executados.

1. Configurando um Projeto Maven

Podemos configurar um build Maven para executar nossos testes unitários no JUnit Jupiter seguindo estes dois passos:

Passo 1: Declarar as Dependências no pom.xml

Primeiro, declaramos a dependência junit-jupiter-api no nosso arquivo pom.xml. Essa dependência fornece a API pública para escrevermos testes e extensões (@Test, @BeforeEach, @DisplayName, Assertions, etc.).

Além disso, para que o runtime de execução e as IDEs executem nossos testes (incluindo suporte opcional a testes legados do JUnit 4 durante uma migração), conhecemos os módulos que compõem a arquitetura do JUnit:

  • junit-jupiter-api: Contém a API pública para escrever testes e extensões no modelo JUnit Jupiter.
  • junit-jupiter-engine: Contém a implementação do motor de execução (TestEngine) do JUnit Jupiter (necessário apenas em tempo de execução — runtime).
  • junit-vintage-engine: Adiciona suporte para executar testes legados escritos em JUnit 3 ou JUnit 4 na nova plataforma.
  • junit-platform-launcher: Fornece a API pública para configurar e iniciar a execução de testes (utilizada por IDEs e ferramentas de build como Gradle e Maven).
  • junit-platform-runner: Permite executar testes e suítes da JUnit Platform dentro de um ambiente legado JUnit 4.

Veja como essas dependências são declaradas na seção <dependencies> do pom.xml:

 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
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-api</artifactId>
    <version>6.0.3</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-engine</artifactId>
    <version>6.0.3</version>
    <scope>test</scope>
</dependency>
<!-- Opcional: apenas se você ainda tiver testes JUnit 4 no mesmo projeto -->
<dependency>
    <groupId>org.junit.vintage</groupId>
    <artifactId>junit-vintage-engine</artifactId>
    <version>6.0.3</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.junit.platform</groupId>
    <artifactId>junit-platform-launcher</artifactId>
    <version>6.0.3</version>
    <scope>test</scope>
</dependency>

Passo 2: Configurar o maven-surefire-plugin

Segundo, declaramos o plugin maven-surefire-plugin na seção <plugins> do nosso pom.xml para que o comando mvn test descubra e execute todos os testes na JUnit Platform:

1
2
3
4
5
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>3.5.2</version>
</plugin>

(Nota histórica: nas primeiras versões milestone do JUnit 5 com Surefire 2.19.1, era necessário declarar a dependência junit-platform-surefire-provider dentro do <plugin>; a partir do Surefire 2.22.0+, o suporte à JUnit Platform já é 100% nativo!).

Vamos ver agora como configurar um projeto Gradle para executar nossos testes unitários.


2. Configurando um Projeto Gradle

No Gradle moderno (Gradle 9 com Kotlin DSL), o suporte à JUnit Platform é nativo na task Test via useJUnitPlatform() (substituindo o antigo plugin org.junit.platform.gradle.plugin usado nas versões iniciais do JUnit 5).

Podemos configurar nosso projeto Gradle seguindo estes passos:

  1. Adicionar a dependência org.junit.jupiter:junit-jupiter-api na configuração testImplementation (API de compilação dos testes).
  2. Adicionar o motor de execução org.junit.jupiter:junit-jupiter-engine e o org.junit.platform:junit-platform-launcher na configuração testRuntimeOnly (necessários apenas em tempo de execução).
  3. Habilitar useJUnitPlatform() na task test.
  4. Configurar testLogging { showStandardStreams = true } dentro da task test. Isso faz com que o Gradle imprima no console tudo o que for escrito em System.out (println) ou System.err durante a execução dos nossos testes — o que nos permitirá visualizar na prática a ordem exata de invocação dos métodos de ciclo de vida!

Depois de seguirmos esses passos, nosso arquivo build.gradle.kts fica assim:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
plugins {
    kotlin("jvm") version "2.4.21"
}

repositories {
    mavenCentral()
}

dependencies {
    testImplementation("org.junit.jupiter:junit-jupiter-api:6.0.3")
    testRuntimeOnly(
        "org.junit.jupiter:junit-jupiter-engine:6.0.3",
        "org.junit.platform:junit-platform-launcher:6.0.3"
    )
}

tasks.withType<Test> {
    useJUnitPlatform()
    testLogging {
        showStandardStreams = true
    }
}

Vamos avançar e escrever a nossa primeira classe de teste demonstrando todo o ciclo de vida do JUnit Jupiter.


3. Escrevendo Nossa Primeira Classe de Teste

Uma classe de teste no JUnit Jupiter é apenas uma classe Kotlin ou Java comum (no Java, ela nem precisa ser public — pode ser package-private). Podemos criar nossa primeira classe de teste em dois passos:

  1. Criar a classe JUnit5ExampleTest.
  2. Anotar a classe com @DisplayName("JUnit 5 Example"). Esse passo é opcional, mas altamente recomendado porque a anotação @DisplayName nos permite usar frases reais e legíveis em vez de apenas nomes técnicos de classes nos relatórios da IDE e do Gradle!
1
2
3
4
5
6
7
package com.testwithspring.intermediate.junit5

import org.junit.jupiter.api.DisplayName

@DisplayName("JUnit 5 Example")
internal class JUnit5ExampleTest {
}

Nosso próximo passo é adicionar os métodos de configuração (setup) e limpeza (teardown) à nossa classe de teste.

3.1. Utilizando Métodos de Setup e Teardown

Os métodos de setup e teardown são métodos que não retornam valor (Unit / void) e são invocados antes ou depois dos métodos de teste (anotados com @Test, @RepeatedTest, @ParameterizedTest ou @TestFactory).

Vamos adicionar todos os quatro métodos de ciclo de vida à nossa classe de teste seguindo estes passos:

  1. @BeforeAll: Adicionamos um método estático beforeAll() anotado com @BeforeAll (dentro de companion object com @JvmStatic em Kotlin). Esse método é invocado uma única vez antes que qualquer método de teste da classe seja executado, e é herdado por subclasses.
  2. @BeforeEach: Adicionamos um método beforeEach() anotado com @BeforeEach. Esse método é invocado antes de cada método de teste, e também é herdado por subclasses.
  3. @AfterEach: Adicionamos um método afterEach() anotado com @AfterEach. Esse método é invocado após cada método de teste, e também é herdado.
  4. @AfterAll: Adicionamos um método estático afterAll() anotado com @AfterAll. Esse método é invocado uma única vez depois que todos os métodos de teste da classe terminaram de executar, e também é herdado.

Depois de adicionarmos todos os métodos de setup e teardown à nossa classe de teste, seu 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
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 Example")
internal class JUnit5ExampleTest {

    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")
    }
}

Dica: Se você anotar sua classe de teste com @TestInstance(TestInstance.Lifecycle.PER_CLASS), os métodos anotados com @BeforeAll e @AfterAll não precisam ser estáticos (companion object / @JvmStatic), podendo ser métodos normais da classe!

3.2. Escrevendo Nossos Primeiros Métodos de Teste (@Test)

Agora vamos adicionar dois métodos de teste anotados com @Test e @DisplayName à nossa classe:

  1. O método firstTest() possui o display name "First test" e imprime "First test method" no console.
  2. O método secondTest() possui o display name "Second test" e imprime "Second test method" no console.

Veja o código completo da classe JUnit5ExampleTest:

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

@DisplayName("JUnit 5 Example")
internal class JUnit5ExampleTest {

    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")
    }

    @Test
    @DisplayName("First test")
    fun firstTest() {
        println("First test method")
    }

    @Test
    @DisplayName("Second test")
    fun secondTest() {
        println("Second test method")
    }
}

4. Executando Nossos Testes Unitários

Quando executamos nossos testes unitários (./gradlew test), vemos a seguinte saída no console:

1
2
3
4
5
6
7
8
Before all test methods
Before each test method
First test method
After each test method
Before each test method
Second test method
After each test method
After all test methods

Essa saída comprova visualmente que os métodos de setup (@BeforeAll, @BeforeEach), os métodos de teste (@Test) e os métodos de teardown (@AfterEach, @AfterAll) são executados exatamente na ordem esperada!


Resumo

Esta aula bônus nos ensinou quatro pontos essenciais:

  • A dependência junit-jupiter-api fornece as anotações e asserções para escrevermos testes e extensões.
  • Devemos garantir que o motor de execução (junit-jupiter-engine) esteja presente no classpath de runtime (testRuntimeOnly no Gradle ou maven-surefire-plugin no Maven) ao executarmos nossos testes.
  • O JUnit Jupiter suporta classes e métodos de teste não públicos (internal em Kotlin / package-private em Java) e fornece o ciclo de vida completo com @BeforeAll, @BeforeEach, @AfterEach e @AfterAll.
  • Podemos sobrescrever o nome exibido de qualquer classe ou método de teste utilizando a anotação @DisplayName (ou nomes de função entre crases no Kotlin).