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,AfterEachCallbackeAfterAllCallback) 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:
- Toda extensão deve implementar a interface marcadora
Extension: Na prática, nunca implementamosExtensiondiretamente; implementamos uma ou mais subinterfaces especializadas chamadas Extension Points (pontos de extensão). - 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@RegisterExtensionem um campo). - 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.Storefornecido 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:
|
|
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:
|
|
Nossa extensão LoggerInjectorExtension verifica se testInstance implementa Loggable e, em caso afirmativo, cria e atribui o Logger:
|
|
Agora podemos registrar LoggerInjectorExtension com @ExtendWith na nossa classe LoggerTest:
|
|
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:
|
|
Vamos criar uma extensão chamada ConditionalTestRunnerExtension que desabilita automaticamente qualquer método de teste cujo nome comece com o prefixo "ignore":
|
|
Vamos testar nossa extensão em uma classe ConditionalTest:
|
|
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:
supportsParameter(parameterContext: ParameterContext, extensionContext: ExtensionContext): Boolean: retornatruese esta extensão souber fornecer um valor para o parâmetro inspecionado.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:
|
|
Agora podemos receber message: String diretamente como parâmetro do nosso método @Test:
|
|
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:
|
|
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):
|
|
Vamos observar o efeito dessa extensão na classe ExceptionTest:
|
|
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!):
BeforeAllCallback: métodobeforeAll(context)— invocado uma única vez antes que todos os métodos de teste de um contêiner sejam executados.BeforeEachCallback: métodobeforeEach(context)— invocado antes da execução de cada método de teste (logo antes dos métodos@BeforeEachda classe).BeforeTestExecutionCallback: métodobeforeTestExecution(context)— invocado imediatamente antes do método@Testser invocado (depois que todos os métodos@BeforeEachjá rodaram). Ideal para iniciar cronômetros (timers) de benchmark!AfterTestExecutionCallback: métodoafterTestExecution(context)— invocado imediatamente após o método@Testterminar (antes que qualquer método@AfterEachrode).AfterEachCallback: métodoafterEach(context)— invocado após cada método de teste e seus respectivos métodos@AfterEach.AfterAllCallback: métodoafterAll(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:
|
|
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:
|
|
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:
|
|
Observe como os callbacks da extensão envolvem (“abraçam”) os métodos anotados da classe de teste:
beforeAllda extensão roda antes do@BeforeAllda classe.beforeEachda extensão roda antes do@BeforeEachda classe, ebeforeTestExecutionroda depois do@BeforeEach(imediatamente antes do@Test).afterTestExecutionroda imediatamente depois do@Test(antes do@AfterEach), eafterEachda extensão roda depois do@AfterEachda classe.afterAllda extensão roda depois do@AfterAllda 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 (BeforeAllCallbackatéAfterAllCallback). - Caso uma extensão precise guardar estado entre callbacks (por exemplo, salvar o instante inicial em
beforeTestExecutione calcular a duração emafterTestExecution), utilizecontext.getStore(Namespace.create(...))para manter a extensão 100% stateless e segura para execução paralela de testes!