Arquitetura do JUnit Platform, Test Engines e Suítes com Tags

Ao concluirmos esta aula, nós:

  • Compreenderemos as responsabilidades de um executor de testes (test runner / test engine) no JUnit.
  • Saberemos como o antigo @RunWith do JUnit 4 evoluiu para a arquitetura do JUnit Platform e @ExtendWith no JUnit Jupiter 6.
  • Seremos capazes de categorizar testes com @Tag (substituindo o antigo Categories runner) e criar suítes de teste seletivas com @Suite, @SelectClasses e @IncludeTags.
Esta aula assume que:

O Que É um Test Runner (e Como Ele Evoluiu para Test Engine)?

A responsabilidade fundamental de um executor de testes (test runner) é instanciar nossas classes de teste, executar os métodos de teste encontrados nessas classes e reportar os resultados obtidos (sucesso, falha ou ignorado).

No JUnit 4 clássico, essa arquitetura funcionava da seguinte maneira:

  • Todos os test runners precisavam estender a classe abstrata org.junit.runner.Runner.
  • Podíamos escolher um test runner customizado anotando nossa classe de teste com @RunWith. Se uma classe não tivesse essa anotação, o JUnit 4 a executava usando o runner padrão chamado BlockJUnit4ClassRunner.
  • Limitação severa: Uma classe de teste podia usar apenas um único test runner por vez (não era possível combinar, por exemplo, o SpringJUnit4ClassRunner com o runner Parameterized na mesma classe).

No JUnit 6, essa arquitetura monolítica foi completamente redesenhada e dividida em três camadas claras:

flowchart TD subgraph Gradle["Gradle 9 (jvm-test-suite)"] Launcher["JUnit Platform Launcher"] end subgraph Platform["JUnit Platform"] EngineAPI["TestEngine API"] end subgraph Engines["Motores de Execução (Test Engines)"] Jupiter["junit-jupiter-engine (JUnit 6)"] Kotest["kotest-runner-junit5 (Kotest 6)"] Suite["junit-platform-suite-engine (@Suite)"] end Launcher --> EngineAPI EngineAPI --> Jupiter EngineAPI --> Kotest EngineAPI --> Suite
  • JUnit Platform: Serve como a fundação para lançar frameworks de teste na JVM. Define a TestEngine API.
  • JUnit Jupiter: É a combinação do novo modelo de programação (anotações @Test, @BeforeEach, @Tag, @ExtendWith) e do motor junit-jupiter-engine que executa esses testes.
  • Fim da limitação de um único Runner: Em vez de @RunWith, o JUnit Jupiter utiliza @ExtendWith (que aceita múltiplas extensões simultaneamente) e @Suite (através do junit-platform-suite) para orquestrar suítes de teste.

Agora que conhecemos os fundamentos arquiteturais, vamos ver como invocamos motores e extensões não-padrão e como criamos suítes de teste categorizadas.


Substituindo o Antigo @RunWith: Extensões e Suítes Declarativas

No JUnit 4, quando queríamos usar um runner diferente do padrão (como um CustomRunner ou SpringRunner), precisávamos anotar nossa classe com @RunWith(CustomRunner.class):

1
2
3
// Legado JUnit 4 (NÃO utilizado no JUnit 6):
// @RunWith(CustomRunner::class)
// class FooBarTest

No JUnit Jupiter 6, existem dois mecanismos modernos que substituem o antigo @RunWith dependendo do seu objetivo:

  1. Para adicionar comportamentos ao redor do teste (como suporte ao Spring ou MockK): utilizamos @ExtendWith, que permite combinar quantas extensões forem necessárias na mesma classe:
1
2
3
4
5
import org.junit.jupiter.api.extension.ExtendWith

@ExtendWith(CustomExtension::class)
class FooBarTest {
}
  1. Para executar uma classe como uma Suíte de Testes agregadora: utilizamos a anotação @Suite fornecida pelo módulo org.junit.platform:junit-platform-suite:
1
2
3
4
5
import org.junit.platform.suite.api.Suite

@Suite
class MinhaSuiteDeTestes {
}

A seguir, usaremos esse conhecimento para criar uma suíte de testes filtrada por categorias/tags — o equivalente moderno no JUnit 6 ao antigo Categories test runner do JUnit 4.


Criando uma Suíte de Testes com @Tag e @Suite (Substituindo o Categories Runner)

A maneira mais simples de criar uma suíte de testes é agrupar classes com @Suite e @SelectClasses. Essa suíte executará todos os métodos de teste encontrados nas classes selecionadas. Contudo, em projetos reais, frequentemente precisamos incluir e/ou excluir determinados testes da nossa suíte (por exemplo, separar testes rápidos da categoria A dos testes da categoria B).

No JUnit 4, isso exigia criar interfaces marcadoras vazias (interface CategoryA), anotar as classes com @Category(CategoryA.class) e usar @RunWith(Categories.class). No JUnit Jupiter 6, todo esse mecanismo ficou muito mais simples e nativo com a anotação @Tag.

Vamos descobrir passo a passo como criar classes categorizadas e suítes seletivas no JUnit 6 com Kotlin.

Passo 1: Definindo Nossos Identificadores de Categoria (Tags)

No JUnit 4, éramos obrigados a criar interfaces Java vazias apenas para servir de marcador:

1
2
3
// Como era no JUnit 4 (Interfaces marcadoras):
interface CategoryA
interface CategoryB

No JUnit Jupiter 6, as categorias são representadas por simples Strings através da anotação @Tag("nomeDaTag"). Para manter a segurança de tipos e evitar erros de digitação em Kotlin, uma excelente prática é definir constantes em um object (ou até mesmo criar suas próprias meta-anotações):

1
2
3
4
object TestCategories {
    const val CATEGORY_A = "categoryA"
    const val CATEGORY_B = "categoryB"
}

Depois de definirmos nossos identificadores de categoria, precisamos escrever nossas classes de teste e configurar as categorias (tags) dos nossos métodos de teste.

Passo 2: Escrevendo Nossas Classes de Teste com @Tag

Podemos configurar as categorias dos nossos testes utilizando a anotação @Tag (org.junit.jupiter.api.Tag). Ao usar @Tag, seguimos duas regras simples:

  • Se quisermos adicionar todos os métodos de teste de uma classe a uma categoria específica, anotamos a própria classe de teste com @Tag("...").
  • Se quisermos adicionar um método de teste individual a uma categoria específica, anotamos apenas a função de teste com @Tag("...").

Vamos criar duas classes de teste:

Primeiro, a classe ATest contém um método de teste chamado testA(). Esse método simplesmente imprime a string 'This test belongs to the category A' no console. Todos os métodos desta classe pertencem à categoria categoryA.

O código-fonte da classe ATest é o seguinte:

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

@Tag("categoryA")
class ATest {

    @Test
    fun testA() {
        println("This test belongs to the category A")
    }
}

Segundo, a classe BTest contém um método de teste chamado testB() que imprime a string 'This test belongs to the category B' no console. Todos os métodos encontrados nesta classe pertencem à categoria categoryB.

O código-fonte da classe BTest é o seguinte:

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

@Tag("categoryB")
class BTest {

    @Test
    fun testB() {
        println("This test belongs to the category B")
    }
}

Agora que criamos nossas classes de teste categorizadas, vamos descobrir como criar as suítes de teste que filtram e executam esses métodos.

Passo 3: Criando Nossas Suítes de Teste (@Suite)

Para usar suítes declarativas no Gradle 9 (Kotlin DSL), incluímos a dependência org.junit.platform:junit-platform-suite na nossa suíte de testes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// build.gradle.kts
testing {
    suites {
        val test by getting(JvmTestSuite::class) {
            useJUnitJupiter("6.0.3")
            dependencies {
                implementation("org.junit.platform:junit-platform-suite:1.11.0")
            }
        }
    }
}

Podemos criar uma suíte de testes filtrada por tags seguindo três passos:

Primeiro, criamos uma classe em Kotlin e a anotamos com @Suite (que substitui o antigo @RunWith(Categories.class)).

Segundo, configuramos as categorias (tags) incluídas e/ou excluídas seguindo estas regras:

  • Podemos incluir categorias anotando a classe da suíte com @IncludeTags (substituta de @IncludeCategory) e passando os nomes das tags desejadas.
  • Podemos excluir categorias anotando a classe da suíte com @ExcludeTags (substituta de @ExcludeCategory) e passando os nomes das tags que devem ser ignoradas.

Terceiro, configuramos as classes de teste que fazem parte do escopo de busca da nossa suíte. Fazemos isso anotando a classe da suíte com @SelectClasses (substituta da antiga @SuiteClasses) e informando as referências das classes (ATest::class, BTest::class).

Vamos inspecionar a implementação completa das classes de teste categorizadas, das suítes de execução seletiva e dos testes de arquitetura da plataforma em nosso projeto:

JUnitPlatformArchitectureTest.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
45
46
47
48
49
50
51
52
53
54
55
package com.testwithspring.starter.lifecycle

import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Test
import org.junit.platform.suite.api.IncludeTags
import org.junit.platform.suite.api.SelectClasses
import org.junit.platform.suite.api.Suite

object TestCategories {
    const val CATEGORY_A = "categoryA"
    const val CATEGORY_B = "categoryB"
}

@Tag(TestCategories.CATEGORY_A)
class ATest {

    @Test
    fun testA() {
        println("This test belongs to the category A")
    }
}

@Tag(TestCategories.CATEGORY_B)
class BTest {

    @Test
    fun testB() {
        println("This test belongs to the category B")
    }
}

@Suite
@IncludeTags("categoryA")
@SelectClasses(ATest::class, BTest::class)
class ATestSuite

@Suite
@IncludeTags("categoryB")
@SelectClasses(ATest::class, BTest::class)
class BTestSuite

class JUnitPlatformArchitectureTest {

    @Test
    @Tag(TestCategories.CATEGORY_A)
    fun verifyCategoryAExecution() {
        println("Executing test under categoryA tag")
    }

    @Test
    @Tag(TestCategories.CATEGORY_B)
    fun verifyCategoryBExecution() {
        println("Executing test under categoryB tag")
    }
}
Dica Prática com Gradle 9: Além de criar classes anotadas com @Suite, o Gradle 9 permite filtrar tags diretamente na tarefa de teste do build.gradle.kts usando useJUnitPlatform { includeTags("categoryA") }, sem nem mesmo precisar criar classes de suíte extras!

Agora que criamos nossas duas suítes de teste, vamos ver o que acontece quando as executamos.


Executando Nossas Suítes de Teste

Quando executamos as suítes e classes de teste categorizadas com o Gradle (usando --info), vemos a separação nítida e isolada da execução de cada categoria filtrada pelo Test Engine do JUnit Platform:

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]).
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 :

> Task :compileKotlin UP-TO-DATE
> Task :processResources NO-SOURCE
> Task :classes UP-TO-DATE
> Task :compileTestKotlin UP-TO-DATE
> Task :processTestResources NO-SOURCE
> Task :testClasses UP-TO-DATE
> 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.ATest > testA() STANDARD_OUT
    This test belongs to the category A
com.testwithspring.starter.lifecycle.ATest > testA() PASSED

com.testwithspring.starter.lifecycle.BTest > testB() STANDARD_OUT
    This test belongs to the category B
com.testwithspring.starter.lifecycle.BTest > testB() PASSED

com.testwithspring.starter.lifecycle.JUnitPlatformArchitectureTest > verifyCategoryAExecution() STANDARD_OUT
    Executing test under categoryA tag
com.testwithspring.starter.lifecycle.JUnitPlatformArchitectureTest > verifyCategoryAExecution() PASSED

com.testwithspring.starter.lifecycle.JUnitPlatformArchitectureTest > verifyCategoryBExecution() STANDARD_OUT
    Executing test under categoryB tag
com.testwithspring.starter.lifecycle.JUnitPlatformArchitectureTest > verifyCategoryBExecution() PASSED

JUnit Platform Suite & Tag Filtering > 4 tests completed, 0 failed, 0 skipped
Finished generating test XML results (0.010 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.018 secs) into: /home/wc/test-with-spring/code/01-starter-junit6/build/reports/tests/test

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

Em outras palavras, mesmo que as suítes apontem para @SelectClasses(ATest::class, BTest::class), filtramos e executamos com sucesso apenas os testes da categoria desejada!

Vamos resumir o que aprendemos nesta aula.


Resumo

Esta aula nos ensinou quatro conceitos essenciais:

  • Um motor de execução (Test Runner no JUnit 4 / TestEngine no JUnit 6) é responsável por instanciar as classes de teste, executar seus métodos de teste e reportar os resultados.
  • Enquanto o JUnit 4 usava @RunWith e limitava cada classe a um único runner, o JUnit Jupiter 6 separa a plataforma (JUnit Platform) das extensões (@ExtendWith) e suítes (@Suite), permitindo composição livre.
  • Substituímos as antigas interfaces marcadoras e @Category pela anotação nativa @Tag("nome") do JUnit Jupiter.
  • A combinação de @Suite, @SelectClasses, @IncludeTags e @ExcludeTags nos dá controle total para incluir ou excluir grupos de testes em diferentes suítes de execução.