Criando Extensões Customizadas no JUnit 6

Depois de concluirmos esta aula, nós:

  • Compreenderemos a filosofia de design e as regras fundamentais do modelo de extensões do JUnit Jupiter.
  • Saberemos como pós-processar uma instância de teste após a sua criação com TestInstancePostProcessor.
  • Saberemos como habilitar ou desabilitar testes programaticamente em tempo de execução com ExecutionCondition.
  • Saberemos como resolver e injetar parâmetros dinamicamente em construtores e métodos de teste com ParameterResolver.
  • Saberemos como interceptar e tratar exceções lançadas por métodos de teste com TestExecutionExceptionHandler.
  • Dominaremos todos os callbacks do ciclo de vida de um teste (BeforeAllCallback, BeforeEachCallback, BeforeTestExecutionCallback, AfterTestExecutionCallback, AfterEachCallback e AfterAllCallback) e sua ordem exata de invocação.

Vamos começar entendendo o modelo de extensões do JUnit 6.


1. Introdução ao Modelo de Extensões do JUnit 6

No JUnit 4, havia dois mecanismos concorrentes e limitados para estender o comportamento de um teste: @RunWith (que permitia apenas um único Runner por classe) e @Rule / @ClassRule.

No JUnit Jupiter (JUnit 5 e JUnit 6), ambos foram substituídos por um conceito único, coeso e componível: a API Extension. Uma extensão do JUnit 6 permite estender o comportamento de classes ou métodos de teste, podendo ser reutilizada em múltiplos testes.

Uma extensão do JUnit 6 segue três regras fundamentais:

  1. Toda extensão deve implementar a interface marcadora Extension: Na prática, nunca implementamos Extension diretamente; implementamos uma ou mais subinterfaces especializadas chamadas Extension Points (pontos de extensão).
  2. Ativação Declarativa ou Programática: Podemos registrar uma ou mais extensões declarativamente anotando uma classe de teste, método de teste ou meta-anotação composta com @ExtendWith(MinhaExtensao::class) (ou programaticamente via @RegisterExtension em um campo).
  3. Sem Estado Interno (Stateless): O JUnit Jupiter não garante quando uma extensão é instanciada nem por quanto tempo ela permanece na memória. Portanto, uma extensão não deve manter estado mutável em propriedades próprias; qualquer estado que precise ser compartilhado entre callbacks de uma execução deve ser salvo e lido no ExtensionContext.Store fornecido pelo JUnit.

Vamos agora construir cinco extensões práticas explorando os principais pontos de extensão do JUnit 6!


2. Pós-Processando a Instância de Teste (TestInstancePostProcessor)

Se quisermos executar código customizado logo após o JUnit instanciar nossa classe de teste (por exemplo, para injetar dependências ou configurar propriedades na instância recém-criada), devemos implementar a interface TestInstancePostProcessor. Ela define um único método:

1
fun postProcessTestInstance(testInstance: Any, context: ExtensionContext)

Para demonstrar como ela funciona, vamos criar uma extensão que injeta uma instância de java.util.logging.Logger na nossa classe de teste sempre que a classe de teste implementar a interface Loggable:

1
2
3
4
5
6
7
package com.example.extensions

import java.util.logging.Logger

interface Loggable {
    var logger: Logger
}

Nossa extensão LoggerInjectorExtension verifica se testInstance implementa Loggable e, em caso afirmativo, cria e atribui o Logger:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
package com.example.extensions

import org.junit.jupiter.api.extension.ExtensionContext
import org.junit.jupiter.api.extension.TestInstancePostProcessor
import java.util.logging.Logger

class LoggerInjectorExtension : TestInstancePostProcessor {

    override fun postProcessTestInstance(
        testInstance: Any,
        context: ExtensionContext
    ) {
        if (testInstance is Loggable) {
            val logger = Logger.getLogger(testInstance.javaClass.name)
            testInstance.logger = logger
        }
    }
}

Agora podemos registrar LoggerInjectorExtension com @ExtendWith na nossa classe LoggerTest:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
package com.example.extensions

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import java.util.logging.Logger

@ExtendWith(LoggerInjectorExtension::class)
class LoggerTest : Loggable {

    override lateinit var logger: Logger

    @Test
    fun shouldInjectLoggerIntoTestClass() {
        assertThat(logger).isNotNull()
        logger.info("O Logger foi injetado com sucesso pela extensão!")
    }
}

3. Avaliando Condições de Execução (ExecutionCondition)

Se quisermos decidir dinamicamente em tempo de execução se um contêiner (classe) ou método de teste deve ser executado ou ignorado (por exemplo, verificando variáveis de ambiente, sistema operacional, feature flags ou convenções de nomenclatura), devemos implementar a interface ExecutionCondition:

1
fun evaluateExecutionCondition(context: ExtensionContext): ConditionEvaluationResult

Vamos criar uma extensão chamada ConditionalTestRunnerExtension que desabilita automaticamente qualquer método de teste cujo nome comece com o prefixo "ignore":

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package com.example.extensions

import org.junit.jupiter.api.extension.ConditionEvaluationResult
import org.junit.jupiter.api.extension.ExecutionCondition
import org.junit.jupiter.api.extension.ExtensionContext

class ConditionalTestRunnerExtension : ExecutionCondition {

    override fun evaluateExecutionCondition(context: ExtensionContext): ConditionEvaluationResult {
        val testMethod = context.testMethod.orElse(null)
            ?: return ConditionEvaluationResult.enabled("Sempre habilita classes de teste")

        return if (testMethod.name.startsWith("ignore")) {
            ConditionEvaluationResult.disabled("O nome do método de teste começa com 'ignore'")
        } else {
            ConditionEvaluationResult.enabled("O método de teste deve ser executado normalmente")
        }
    }
}

Vamos testar nossa extensão em uma classe ConditionalTest:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
package com.example.extensions

import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith

@ExtendWith(ConditionalTestRunnerExtension::class)
class ConditionalTest {

    @Test
    fun ignoreThisTestMethod() {
        println("Este método NÃO deve ser executado")
    }

    @Test
    fun runThisTestMethod() {
        println("Este método DEVE ser executado")
    }
}

Ao executar ConditionalTest, o método ignoreThisTestMethod() aparece marcado como ignorado (skipped) no relatório com o motivo "O nome do método de teste começa com 'ignore'", enquanto runThisTestMethod() é executado normalmente!


4. Resolvendo Parâmetros de Métodos e Construtores (ParameterResolver)

No JUnit 6, construtores de classes de teste, métodos de ciclo de vida (@BeforeAll, @BeforeEach, @AfterEach, @AfterAll) e métodos @Test podem declarar parâmetros. Para ensinar o JUnit a injetar um tipo específico de parâmetro, implementamos a interface ParameterResolver, que possui dois métodos:

  1. supportsParameter(parameterContext: ParameterContext, extensionContext: ExtensionContext): Boolean: retorna true se esta extensão souber fornecer um valor para o parâmetro inspecionado.
  2. resolveParameter(parameterContext: ParameterContext, extensionContext: ExtensionContext): Any?: instancia e retorna o valor que será passado para aquele parâmetro.

Vamos criar a extensão MessageResolverExtension, que injeta a mensagem "Hello World!" sempre que um método de teste solicitar um parâmetro do tipo String:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.example.extensions

import org.junit.jupiter.api.extension.ExtensionContext
import org.junit.jupiter.api.extension.ParameterContext
import org.junit.jupiter.api.extension.ParameterResolver

class MessageResolverExtension : ParameterResolver {

    override fun supportsParameter(
        parameterContext: ParameterContext,
        extensionContext: ExtensionContext
    ): Boolean {
        return parameterContext.parameter.type == String::class.java
    }

    override fun resolveParameter(
        parameterContext: ParameterContext,
        extensionContext: ExtensionContext
    ): Any {
        return "Hello World!"
    }
}

Agora podemos receber message: String diretamente como parâmetro do nosso método @Test:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package com.example.extensions

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith

@ExtendWith(MessageResolverExtension::class)
class ParameterTest {

    @Test
    fun shouldInjectMessageAsMethodParameter(message: String) {
        assertThat(message).isEqualTo("Hello World!")
    }
}

5. Tratando Exceções Lançadas por Testes (TestExecutionExceptionHandler)

Se quisermos interceptar exceções lançadas durante a execução de um método @Test (por exemplo, para registrar logs de diagnóstico extras, tirar um screenshot ou ignorar um tipo específico de exceção), implementamos a interface TestExecutionExceptionHandler:

1
fun handleTestExecutionException(context: ExtensionContext, throwable: Throwable)

Vamos criar uma extensão chamada IgnoreRuntimeExceptionExtension que engole qualquer RuntimeException (fazendo o teste passar), mas relança qualquer outra exceção (como Exception verificada ou AssertionError):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
package com.example.extensions

import org.junit.jupiter.api.extension.ExtensionContext
import org.junit.jupiter.api.extension.TestExecutionExceptionHandler

class IgnoreRuntimeExceptionExtension : TestExecutionExceptionHandler {

    override fun handleTestExecutionException(
        context: ExtensionContext,
        throwable: Throwable
    ) {
        if (throwable is RuntimeException) {
            // Ignora a RuntimeException engolindo-a silenciosamente
            return
        }
        throw throwable
    }
}

Vamos observar o efeito dessa extensão na classe ExceptionTest:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package com.example.extensions

import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith

@ExtendWith(IgnoreRuntimeExceptionExtension::class)
class ExceptionTest {

    @Test
    fun shouldIgnoreRuntimeException() {
        throw RuntimeException("Esta exceção será ignorada pela extensão e o teste passará!")
    }

    // Se descomentarmos o teste abaixo, ele FALHARÁ porque Exception não é uma RuntimeException:
    // @Test
    // fun shouldNotIgnoreCheckedException() {
    //     throw Exception("Esta exceção fará o teste falhar!")
    // }
}

6. Callbacks do Ciclo de Vida do Teste

Por fim, o JUnit Jupiter oferece seis interfaces de callback que nos permitem executar lógica customizada exatamente antes ou depois de cada fase do ciclo de vida de um teste (é assim que extensões como Testcontainers, WireMock e Mockito/MockK gerenciam recursos automaticamente!):

  1. BeforeAllCallback: método beforeAll(context) — invocado uma única vez antes que todos os métodos de teste de um contêiner sejam executados.
  2. BeforeEachCallback: método beforeEach(context) — invocado antes da execução de cada método de teste (logo antes dos métodos @BeforeEach da classe).
  3. BeforeTestExecutionCallback: método beforeTestExecution(context) — invocado imediatamente antes do método @Test ser invocado (depois que todos os métodos @BeforeEach já rodaram). Ideal para iniciar cronômetros (timers) de benchmark!
  4. AfterTestExecutionCallback: método afterTestExecution(context) — invocado imediatamente após o método @Test terminar (antes que qualquer método @AfterEach rode).
  5. AfterEachCallback: método afterEach(context) — invocado após cada método de teste e seus respectivos métodos @AfterEach.
  6. AfterAllCallback: método afterAll(context) — invocado após todos os métodos de teste de um contêiner terem sido executados.

Vamos criar uma extensão chamada LifecycleLoggerExtension que implementa todas as seis interfaces para visualizarmos a ordem exata de execução:

 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
package com.example.extensions

import org.junit.jupiter.api.extension.AfterAllCallback
import org.junit.jupiter.api.extension.AfterEachCallback
import org.junit.jupiter.api.extension.AfterTestExecutionCallback
import org.junit.jupiter.api.extension.BeforeAllCallback
import org.junit.jupiter.api.extension.BeforeEachCallback
import org.junit.jupiter.api.extension.BeforeTestExecutionCallback
import org.junit.jupiter.api.extension.ExtensionContext

class LifecycleLoggerExtension :
    BeforeAllCallback,
    BeforeEachCallback,
    BeforeTestExecutionCallback,
    AfterTestExecutionCallback,
    AfterEachCallback,
    AfterAllCallback {

    override fun beforeAll(context: ExtensionContext) {
        println("Before all test methods")
    }

    override fun beforeEach(context: ExtensionContext) {
        println("Before each test method")
    }

    override fun beforeTestExecution(context: ExtensionContext) {
        println("Before test execution")
    }

    override fun afterTestExecution(context: ExtensionContext) {
        println("After test execution")
    }

    override fun afterEach(context: ExtensionContext) {
        println("After each test method")
    }

    override fun afterAll(context: ExtensionContext) {
        println("After all test methods")
    }
}

Agora vamos criar uma classe LifecycleTest anotada com @ExtendWith(LifecycleLoggerExtension::class) contendo todos os métodos de ciclo de vida e também uma classe interna @Nested:

 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.example.extensions

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

@ExtendWith(LifecycleLoggerExtension::class)
class LifecycleTest {

    companion object {
        @BeforeAll
        @JvmStatic
        fun beforeAll() {
            println("@BeforeAll method")
        }

        @AfterAll
        @JvmStatic
        fun afterAll() {
            println("@AfterAll method")
        }
    }

    @BeforeEach
    fun beforeEach() {
        println("@BeforeEach method")
    }

    @AfterEach
    fun afterEach() {
        println("@AfterEach method")
    }

    @Test
    fun testMethod() {
        println("@Test method")
    }

    @Nested
    inner class A {

        @BeforeEach
        fun beforeEachNested() {
            println("Nested @BeforeEach method")
        }

        @AfterEach
        fun afterEachNested() {
            println("Nested @AfterEach method")
        }

        @Test
        fun nestedTestMethod() {
            println("Nested @Test method")
        }
    }
}

Quando executamos LifecycleTest, vemos no console a sequência exata de execução entre os callbacks da extensão e os métodos da classe de teste:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
Before all test methods
@BeforeAll method
Before each test method
@BeforeEach method
Before test execution
@Test method
After test execution
@AfterEach method
After each test method
Before all test methods
Before each test method
@BeforeEach method
Nested @BeforeEach method
Before test execution
Nested @Test method
After test execution
Nested @AfterEach method
@AfterEach method
After each test method
After all test methods
@AfterAll method
After all test methods

Observe como os callbacks da extensão envolvem (“abraçam”) os métodos anotados da classe de teste:

  • beforeAll da extensão roda antes do @BeforeAll da classe.
  • beforeEach da extensão roda antes do @BeforeEach da classe, e beforeTestExecution roda depois do @BeforeEach (imediatamente antes do @Test).
  • afterTestExecution roda imediatamente depois do @Test (antes do @AfterEach), e afterEach da extensão roda depois do @AfterEach da classe.
  • afterAll da extensão roda depois do @AfterAll da classe.

Resumo

Nesta aula que encerra o Capítulo 02 (Pacote Intermediário), desvendamos o funcionamento interno das extensões do JUnit 6:

  • O modelo de extensões unifica a customização do JUnit por meio de interfaces especializadas (Extension Points) registradas via @ExtendWith.
  • Podemos pós-processar instâncias de teste (TestInstancePostProcessor), filtrar execuções dinamicamente (ExecutionCondition), injetar parâmetros tipados (ParameterResolver), interceptar exceções (TestExecutionExceptionHandler) e reagir a cada etapa do ciclo de vida (BeforeAllCallback até AfterAllCallback).
  • Caso uma extensão precise guardar estado entre callbacks (por exemplo, salvar o instante inicial em beforeTestExecution e calcular a duração em afterTestExecution), utilize context.getStore(Namespace.create(...)) para manter a extensão 100% stateless e segura para execução paralela de testes!