Modelo de Extensões do JUnit 6 (@ExtendWith e @TempDir)

Ao concluirmos esta aula, nós:

  • Saberemos o que era uma Rule (@Rule) no JUnit 4 e como ela evoluiu para uma Extensão (Extension) no JUnit Jupiter 6.
  • Seremos capazes de registrar e utilizar extensões do JUnit 6 em nossas classes de teste.
  • Seremos capazes de criar arquivos e diretórios temporários que são automaticamente excluídos após a execução de um método de teste utilizando @TempDir (substituindo a antiga TemporaryFolder rule).

O Que É uma Rule / Extensão no JUnit?

No JUnit, uma Rule (no JUnit 4) ou Extensão (no JUnit Jupiter 6) é um componente que intercepta chamadas de métodos de teste e nos permite executar ações customizadas antes que um método de teste seja iniciado, durante sua resolução de parâmetros e depois que o método de teste tiver terminado sua execução.

No JUnit 4 clássico, todas as rules precisavam implementar a interface org.junit.rules.TestRule. No JUnit Jupiter 6, o conceito de @Rule e @ClassRule foi unificado sob a interface base org.junit.jupiter.api.extension.Extension e suas interfaces de ciclo de vida especializadas (como BeforeEachCallback, AfterEachCallback, ParameterResolver).

Podemos fazer coisas muito úteis com extensões e recursos nativos do JUnit 6. Por exemplo, o JUnit Jupiter fornece mecanismos que podem:

  • Criar arquivos e diretórios temporários que são automaticamente limpos e excluídos após a execução de um método de teste (@TempDir).
  • Falhar um teste automaticamente se o tempo limite especificado for excedido antes do término do método (@Timeout).
  • Configurar um recurso externo, como um socket, servidor HTTP (WireMock) ou contêiner de banco de dados (Testcontainers) antes do teste e liberar o recurso automaticamente ao final.

A seguir, descobriremos como ativar e utilizar extensões em nossas classes de teste.


Utilizando Extensões no JUnit 6 (Substituindo @Rule)

No JUnit 4, para usar uma rule como FooBarRule, éramos obrigados a seguir dois passos burocráticos:

  1. Adicionar um campo public não-estático na classe de teste cujo tipo fosse subtipo de TestRule.
  2. Anotar esse campo com @Rule.
1
2
3
4
5
// Legado JUnit 4 (Exigia campo público mutável ou @get:Rule no Kotlin):
// class RuleTest {
//     @get:Rule
//     val rule = FooBarRule()
// }
Nota Histórica: No JUnit 4, o campo anotado com @Rule obrigatoriamente precisava ser public e não podia ser static (caso contrário, exigia @ClassRule). Em Kotlin, como propriedades geram campos privados com getters públicos por padrão, isso exigia o uso da sintaxe @get:Rule ou @JvmField, o que frequentemente confundia desenvolvedores.

No JUnit Jupiter 6, essa limitação desapareceu! Podemos ativar uma extensão de duas formas muito mais limpas:

  1. Forma Declarativa (@ExtendWith): Anotamos a classe (ou método) diretamente com @ExtendWith(FooBarExtension::class).
  2. Forma Programática (@RegisterExtension): Caso precisemos passar parâmetros no construtor da extensão, declaramos uma propriedade na classe anotada com @RegisterExtension (que não precisa ser public!).

Para ilustrar na prática como uma extensão customizada intercepta o ciclo de vida dos testes medindo o tempo decorrido, implementamos a extensão TimingExtension:

TimingExtension.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
package com.testwithspring.starter.lifecycle

import org.junit.jupiter.api.extension.AfterTestExecutionCallback
import org.junit.jupiter.api.extension.BeforeTestExecutionCallback
import org.junit.jupiter.api.extension.ExtensionContext

/**
 * Extensão customizada que demonstra o modelo de extensões no JUnit Jupiter 6.
 * Intercepta o ciclo de vida do método de teste medindo e logando o tempo de execução.
 */
class TimingExtension : BeforeTestExecutionCallback, AfterTestExecutionCallback {

    companion object {
        private const val START_TIME = "start_time"
        private val NAMESPACE = ExtensionContext.Namespace.create(TimingExtension::class.java)
    }

    override fun beforeTestExecution(context: ExtensionContext) {
        getStore(context).put(START_TIME, System.currentTimeMillis())
    }

    override fun afterTestExecution(context: ExtensionContext) {
        val startTime = getStore(context).remove(START_TIME, Long::class.javaPrimitiveType) ?: return
        val duration = System.currentTimeMillis() - startTime
        val testMethod = context.requiredTestMethod.name
        println("Method [$testMethod] took ${duration} ms.")
    }

    private fun getStore(context: ExtensionContext): ExtensionContext.Store {
        return context.getStore(NAMESPACE)
    }
}

Vamos colocar esse conhecimento em prática e descobrir como podemos criar arquivos e diretórios temporários que são excluídos automaticamente após a execução de um método de teste.


Utilizando a Extensão Nativa @TempDir (Substituindo a TemporaryFolder Rule)

Quando precisamos criar arquivos e/ou diretórios que devem ser excluídos após a execução de um método de teste, o JUnit 4 exigia instanciar a rule TemporaryFolder.

No JUnit Jupiter 6, essa funcionalidade já vem embutida nativamente através da anotação @TempDir (org.junit.jupiter.api.io.TempDir). Para habilitá-la, podemos anotar uma propriedade da classe (lateinit var tmpFolder: Path ou File) ou injetá-la diretamente como parâmetro do método @Test!

Depois de habilitarmos o @TempDir em nossa classe de teste, o código-fonte fica assim:

1
2
3
4
5
6
7
8
import org.junit.jupiter.api.io.TempDir
import java.io.File

class TempDirTest {

    @TempDir
    lateinit var tmpFolder: File
}

Agora podemos criar arquivos e diretórios dentro de tmpFolder. Vamos criar um teste simples que cria um arquivo chamado file.txt que será automaticamente excluído pelo JUnit assim que o método de teste terminar.

Em outras palavras, adicionaremos um novo método de teste à nossa classe implementando estes três passos:

  1. Criar um novo arquivo file.txt dentro do diretório temporário tmpFolder e garantir sua criação no sistema de arquivos (createNewFile()).
  2. Garantir que o objeto File criado é realmente um arquivo (isFile).
  3. Garantir que o arquivo foi criado exatamente dentro do diretório raiz da nossa pasta temporária (tmpFolder).

Depois de implementarmos nossa classe de testes integrando @ExtendWith(TimingExtension::class) e @TempDir (demonstrando tanto o suporte a java.io.File quanto a java.nio.file.Path), o código-fonte sincronizado com a nossa suíte de testes fica assim:

ExtensionModelTest.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
package com.testwithspring.starter.lifecycle

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.junit.jupiter.api.io.CleanupMode
import org.junit.jupiter.api.io.TempDir
import java.io.File
import java.nio.file.Path
import kotlin.io.path.createFile
import kotlin.io.path.isRegularFile

@ExtendWith(TimingExtension::class)
class ExtensionModelTest {

    @TempDir
    lateinit var tmpFolder: File

    @TempDir(cleanup = CleanupMode.ON_SUCCESS)
    lateinit var tmpPath: Path

    @Test
    fun shouldCreateNewFileInTemporaryFolder() {
        val created = File(tmpFolder, "file.txt").apply {
            createNewFile()
        }

        assertTrue(created.isFile)
        assertEquals(tmpFolder, created.parentFile)
    }

    @Test
    fun shouldCreateAndVerifyFileUsingPathAndNio() {
        val createdPath = tmpPath.resolve("isolated-file.txt").createFile()

        assertTrue(createdPath.isRegularFile())
        assertEquals("isolated-file.txt", createdPath.fileName.toString())
    }
}

(Dica de Kotlin + NIO.2: Com @TempDir, você também pode usar java.nio.file.Path em vez de java.io.File, o que permite criar o arquivo de forma ainda mais concisa com Files.createFile(tmpDir.resolve("file.txt"))).

Política de Limpeza (CleanupMode) e Garantia de Deleção:

Na antiga TemporaryFolder rule do JUnit 4 (antes da versão 4.13), o JUnit não garantia que uma falha ao deletar os arquivos temporários causaria falha no teste (exigindo configurar manualmente TemporaryFolder.builder().assureDeletion().build()).

No JUnit Jupiter 6, o @TempDir já verifica a exclusão por padrão e permite configurar explicitamente o modo de limpeza (cleanup), por exemplo para manter os arquivos apenas se o teste falhar (ótimo para diagnóstico):
1
2
3
4
5
6
7
8
9
import org.junit.jupiter.api.io.CleanupMode
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path

class TempDirDiagnosticTest {

    @TempDir(cleanup = CleanupMode.ON_SUCCESS)
    lateinit var tmpDir: Path
}

Executando Nossos Testes com Extensões e @TempDir

Quando executamos a suíte de testes no Gradle com a extensão customizada TimingExtension e o @TempDir, observamos a medição transparente do tempo de execução de cada teste pelo interceptador e a deleção automática e segura das pastas temporárias:

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
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.ExtensionModelTest > shouldCreateNewFileInTemporaryFolder() STANDARD_OUT
    Method [shouldCreateNewFileInTemporaryFolder] took 4 ms.
com.testwithspring.starter.lifecycle.ExtensionModelTest > shouldCreateNewFileInTemporaryFolder() PASSED

com.testwithspring.starter.lifecycle.ExtensionModelTest > shouldCreateAndVerifyFileUsingPathAndNio() STANDARD_OUT
    Method [shouldCreateAndVerifyFileUsingPathAndNio] took 2 ms.
com.testwithspring.starter.lifecycle.ExtensionModelTest > shouldCreateAndVerifyFileUsingPathAndNio() PASSED

ExtensionModelTest (@ExtendWith TimingExtension & @TempDir) > 2 tests completed, 0 failed, 0 skipped
Finished generating test XML results (0.009 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.016 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

Vamos resumir o que aprendemos nesta aula.


Resumo

Esta aula nos ensinou quatro pontos essenciais:

  • Uma Rule (no JUnit 4) ou Extensão (no JUnit 6) é um componente que intercepta o ciclo de vida dos métodos de teste, permitindo executar lógicas antes e depois de cada teste.
  • O JUnit 6 unificou as antigas @Rule e @ClassRule sob o modelo de extensões com @ExtendWith e @RegisterExtension.
  • Ao contrário do JUnit 4 (que exigia campos public não-estáticos com @Rule), o JUnit 6 permite injetar extensões declarativamente na classe, em propriedades ou diretamente nos parâmetros do método de teste.
  • A extensão nativa @TempDir (substituta da antiga TemporaryFolder rule) permite criar arquivos e diretórios temporários com File ou Path que são automaticamente removidos após a execução do teste.