Otimizando a Execução de Testes Kotlin no Gradle 9

Ao concluir esta aula, saberemos como criar e configurar um projeto Gradle 9.8.2 (utilizando Kotlin DSL) capaz de compilar e executar tanto testes unitários quanto testes de integração escritos em Kotlin 2.4.21 e JUnit Jupiter 6.0.3.

Esta aula assume que:

Os Requisitos do Nosso Build Gradle

Os requisitos do nosso build Gradle são:

  • Todo o código (aplicação e testes) deve utilizar Kotlin 2.4.21.
  • Os testes unitários e de integração devem possuir diretórios de código-fonte e de recursos (resources) separados.
  • Deve ser possível executar apenas os testes unitários ou apenas os testes de integração isoladamente.
  • Deve ser possível executar ambos os testes (unitários e de integração) em uma única execução.
  • Se um teste de integração falhar, o nosso build também deve falhar.
  • Os testes de integração e os testes unitários devem gerar relatórios HTML em diretórios diferentes.

Vamos começar dando uma rápida olhada no sistema sob teste (System Under Test - SUT).

Introdução ao Sistema Sob Teste (SUT)

Durante esta aula, escreveremos testes unitários e de integração para a classe MessageService. Essa classe possui um único método chamado getMessage(), que retorna a mensagem: 'Hello World!'.

O código-fonte da classe MessageService (localizada em src/main/kotlin) é o seguinte:

1
2
3
4
5
6
class MessageService {

    fun getMessage(): String {
        return "Hello World!"
    }
}

A seguir, veremos como criar um projeto Kotlin simples no Gradle capaz de executar inicialmente apenas testes unitários.

Executando Testes Unitários com o Gradle

Quando queremos criar um projeto Kotlin que execute testes unitários no Gradle 9.8.2 com Kotlin DSL (build.gradle.kts), devemos seguir estes passos:

Primeiro, como estamos utilizando a JVM, devemos aplicar o plugin Gradle do Kotlin JVM (org.jetbrains.kotlin.jvm). Após aplicarmos este plugin, o nosso arquivo build.gradle.kts ficará assim:

1
2
3
plugins {
    kotlin("jvm") version "2.4.21"
}

Segundo, precisamos garantir que o Gradle utilize o repositório Maven Central ao resolver as dependências do nosso projeto Kotlin. Depois de configurarmos o repositório utilizado, o código-fonte do nosso arquivo build.gradle.kts ficará da seguinte forma:

1
2
3
4
5
6
7
plugins {
    kotlin("jvm") version "2.4.21"
}

repositories {
    mavenCentral()
}

Terceiro, devemos especificar as dependências da nossa aplicação. Como nossa aplicação utiliza Kotlin, declaramos a biblioteca padrão do Kotlin (kotlin-stdlib) na configuração de dependências implementation (nota: no Kotlin Gradle Plugin moderno, a kotlin-stdlib também é incluída por padrão, mas declará-la explicitamente deixa a árvore de dependências transparente).

Após fazermos isso, nosso arquivo build.gradle.kts terá a seguinte aparência:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
plugins {
    kotlin("jvm") version "2.4.21"
}

repositories {
    mavenCentral()
}

dependencies {
    implementation(kotlin("stdlib"))
}

Quarto, devemos especificar as dependências de teste. Como queremos escrever testes para uma aplicação Kotlin utilizando Kotlin 2.4.21, JUnit Jupiter 6.0.3 e AssertJ 3.27.7 (podendo também integrar MockK 1.14 para mocks idiomáticos quando necessário), podemos configurar as dependências de teste seguindo estes passos:

  1. Adicionar a dependência do AssertJ Core (opcional, mas recomendada para asserções fluentes) na configuração testImplementation (que substituiu a antiga testCompile no Gradle moderno).
  2. Adicionar a dependência da API do JUnit Jupiter (junit-jupiter-api) na configuração testImplementation.
  3. Adicionar a dependência do motor de execução do JUnit Jupiter (junit-jupiter-engine) e do junit-platform-launcher na configuração testRuntimeOnly (que substituiu a antiga testRuntime).

Depois de especificarmos as dependências de teste, o código-fonte do nosso arquivo build.gradle.kts ficará assim:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
plugins {
    kotlin("jvm") version "2.4.21"
}

repositories {
    mavenCentral()
}

dependencies {
    implementation(kotlin("stdlib"))
    testImplementation("org.assertj:assertj-core:3.27.7")
    testImplementation("org.junit.jupiter:junit-jupiter-api:6.0.3")
    testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:6.0.3")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

Quinto, devemos garantir que o Gradle execute nossos testes unitários utilizando a JUnit Platform (JUnit 6). Após configurarmos a task test, nosso arquivo build.gradle.kts ficará da seguinte maneira:

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

repositories {
    mavenCentral()
}

dependencies {
    implementation(kotlin("stdlib"))
    testImplementation("org.assertj:assertj-core:3.27.7")
    testImplementation("org.junit.jupiter:junit-jupiter-api:6.0.3")
    testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:6.0.3")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

tasks.named<Test>("test") {
    useJUnitPlatform()
}

Agora já configuramos o nosso build Gradle. No entanto, antes de podermos executar nossos testes unitários, precisamos escrever uma classe de teste e colocá-la no diretório src/test/kotlin. O código-fonte da nossa classe de teste é o seguinte:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test

class MessageServiceTest {

    private lateinit var service: MessageService

    @BeforeEach
    fun configureSystemUnderTest() {
        service = MessageService()
    }

    @Test
    @DisplayName("Should return the correct message")
    fun shouldReturnCorrectMessage() {
        val message = service.getMessage()
        assertThat(message).isEqualTo("Hello World!")
    }
}

Agora podemos executar nossos testes unitários utilizando o comando: ./gradlew clean test. Ao executarmos esse comando, vemos que o Gradle compila nossa aplicação e, em seguida, compila e executa nossos testes unitários:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
> Task :clean
> Task :compileKotlin
> Task :compileJava NO-SOURCE
> Task :processResources NO-SOURCE
> Task :classes UP-TO-DATE
> Task :compileTestKotlin
> Task :compileTestJava NO-SOURCE
> Task :processTestResources NO-SOURCE
> Task :testClasses UP-TO-DATE
> Task :test

Se você estiver utilizando o Gradle e quiser visualizar a lista detalhada de todas as tasks invocadas e logs de execução, execute o Gradle utilizando a opção de linha de comando --info.

Leitura Adicional:

Acabamos de criar um projeto Gradle capaz de compilar e executar nossos testes unitários. Contudo, nosso build ainda não possui suporte dedicado para testes de integração. Vamos avançar e descobrir como oferecer suporte tanto para testes unitários quanto para testes de integração.

Executando Testes Unitários e de Integração com o Gradle

Como queremos executar tanto testes unitários quanto testes de integração com o Gradle, precisamos fazer algumas alterações no nosso build. No Gradle legado (versões 5/6), costumava-se utilizar o plugin externo org.unbroken-dome.test-sets; no Gradle 9.8.2, podemos configurar conjuntos de testes (source sets) customizados ou utilizar o plugin nativo jvm-test-suite mantendo exatamente a mesma estrutura física de diretórios (src/integration-test/kotlin e src/integration-test/resources). Vamos realizar essas alterações passo a passo:

Primeiro, como queremos adicionar novos diretórios de código de teste e de recursos ao nosso build Gradle de forma estruturada, aplicamos o plugin jvm-test-suite (ou configuramos os sourceSets do plugin JVM). Após adicionarmos o plugin ao bloco plugins, nosso arquivo build.gradle.kts ficará assim:

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

repositories {
    mavenCentral()
}

dependencies {
    implementation(kotlin("stdlib"))
    testImplementation("org.assertj:assertj-core:3.27.7")
    testImplementation("org.junit.jupiter:junit-jupiter-api:6.0.3")
    testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:6.0.3")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

tasks.named<Test>("test") {
    useJUnitPlatform()
}

Segundo, devemos configurar os diretórios de código-fonte e de recursos dos nossos testes de integração. Podemos configurar esses diretórios seguindo estes passos:

  1. Adicionar um novo conjunto de testes chamado integrationTest ao nosso build Gradle (herdando o classpath da aplicação principal e as dependências de teste).
  2. Alterar o nome do diretório raiz desse conjunto de testes de integrationTest para integration-test (src/integration-test/kotlin e src/integration-test/resources).

Depois de configurarmos esses diretórios e a respectiva task integrationTest, o código-fonte do arquivo build.gradle.kts ficará da seguinte forma:

 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
plugins {
    kotlin("jvm") version "2.4.21"
    `jvm-test-suite`
}

repositories {
    mavenCentral()
}

val integrationTestSourceSet = sourceSets.create("integrationTest") {
    kotlin.srcDir("src/integration-test/kotlin")
    resources.srcDir("src/integration-test/resources")
    compileClasspath += sourceSets.main.get().output + configurations.testRuntimeClasspath.get()
    runtimeClasspath += output + compileClasspath
}

dependencies {
    implementation(kotlin("stdlib"))
    testImplementation("org.assertj:assertj-core:3.27.7")
    testImplementation("org.junit.jupiter:junit-jupiter-api:6.0.3")
    testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:6.0.3")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

val integrationTest = tasks.register<Test>("integrationTest") {
    description = "Runs integration tests."
    group = "verification"
    testClassesDirs = integrationTestSourceSet.output.classesDirs
    classpath = integrationTestSourceSet.runtimeClasspath
}

tasks.named<Test>("test") {
    useJUnitPlatform()
}

Esta configuração adiciona um source set chamado integrationTest ao nosso build e mapeia sua estrutura de pastas para integration-test. Isso significa que o diretório src/integration-test/kotlin contém o código-fonte dos nossos testes de integração e o diretório src/integration-test/resources contém os recursos (resources) dos nossos testes de integração. Além disso, por ser uma task Test separada (integrationTest), o Gradle gera automaticamente relatórios HTML em diretórios distintos (build/reports/tests/test e build/reports/tests/integrationTest).

Terceiro, devemos garantir que os testes unitários sejam executados antes dos testes de integração (shouldRunAfter / mustRunAfter). Após adicionarmos a configuração necessária ao nosso arquivo build.gradle.kts, ele ficará 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
plugins {
    kotlin("jvm") version "2.4.21"
    `jvm-test-suite`
}

repositories {
    mavenCentral()
}

val integrationTestSourceSet = sourceSets.create("integrationTest") {
    kotlin.srcDir("src/integration-test/kotlin")
    resources.srcDir("src/integration-test/resources")
    compileClasspath += sourceSets.main.get().output + configurations.testRuntimeClasspath.get()
    runtimeClasspath += output + compileClasspath
}

dependencies {
    implementation(kotlin("stdlib"))
    testImplementation("org.assertj:assertj-core:3.27.7")
    testImplementation("org.junit.jupiter:junit-jupiter-api:6.0.3")
    testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:6.0.3")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

val integrationTest = tasks.register<Test>("integrationTest") {
    description = "Runs integration tests."
    group = "verification"
    testClassesDirs = integrationTestSourceSet.output.classesDirs
    classpath = integrationTestSourceSet.runtimeClasspath
    mustRunAfter(tasks.named("test"))
}

tasks.named<Test>("test") {
    useJUnitPlatform()
}

Quarto, devemos garantir que os testes de integração sejam executados quando invocarmos a task build (através da task de ciclo de vida check). Além disso, precisamos garantir que o nosso build falhe caso algum teste de integração falhe. Após adicionarmos a configuração necessária ao nosso arquivo build.gradle.kts, ele terá a seguinte aparência:

 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
plugins {
    kotlin("jvm") version "2.4.21"
    `jvm-test-suite`
}

repositories {
    mavenCentral()
}

val integrationTestSourceSet = sourceSets.create("integrationTest") {
    kotlin.srcDir("src/integration-test/kotlin")
    resources.srcDir("src/integration-test/resources")
    compileClasspath += sourceSets.main.get().output + configurations.testRuntimeClasspath.get()
    runtimeClasspath += output + compileClasspath
}

dependencies {
    implementation(kotlin("stdlib"))
    testImplementation("org.assertj:assertj-core:3.27.7")
    testImplementation("org.junit.jupiter:junit-jupiter-api:6.0.3")
    testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:6.0.3")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

val integrationTest = tasks.register<Test>("integrationTest") {
    description = "Runs integration tests."
    group = "verification"
    testClassesDirs = integrationTestSourceSet.output.classesDirs
    classpath = integrationTestSourceSet.runtimeClasspath
    mustRunAfter(tasks.named("test"))
}

tasks.named("check") {
    dependsOn(integrationTest)
}

tasks.named<Test>("test") {
    useJUnitPlatform()
}

Quinto, devemos garantir que o Gradle execute nossos testes de integração utilizando o JUnit Jupiter (JUnit Platform). Depois de configurarmos useJUnitPlatform() na task integrationTest, nosso arquivo build.gradle.kts ficará 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
36
37
38
39
40
plugins {
    kotlin("jvm") version "2.4.21"
    `jvm-test-suite`
}

repositories {
    mavenCentral()
}

val integrationTestSourceSet = sourceSets.create("integrationTest") {
    kotlin.srcDir("src/integration-test/kotlin")
    resources.srcDir("src/integration-test/resources")
    compileClasspath += sourceSets.main.get().output + configurations.testRuntimeClasspath.get()
    runtimeClasspath += output + compileClasspath
}

dependencies {
    implementation(kotlin("stdlib"))
    testImplementation("org.assertj:assertj-core:3.27.7")
    testImplementation("org.junit.jupiter:junit-jupiter-api:6.0.3")
    testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:6.0.3")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

val integrationTest = tasks.register<Test>("integrationTest") {
    description = "Runs integration tests."
    group = "verification"
    testClassesDirs = integrationTestSourceSet.output.classesDirs
    classpath = integrationTestSourceSet.runtimeClasspath
    mustRunAfter(tasks.named("test"))
    useJUnitPlatform()
}

tasks.named("check") {
    dependsOn(integrationTest)
}

tasks.named<Test>("test") {
    useJUnitPlatform()
}

Sexto, devemos filtrar os testes invocados utilizando tags do JUnit Jupiter (@Tag). Podemos fazer isso seguindo estes passos:

  1. Garantir que o JUnit execute apenas os testes que possuem a tag unitTest quando executarmos nossos testes unitários.
  2. Garantir que o JUnit execute apenas os testes que possuem a tag integrationTest quando executarmos nossos testes de integração.

Após configurarmos a filtragem dos testes invocados, o código-fonte final do nosso arquivo build.gradle.kts ficará da seguinte forma:

 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
plugins {
    kotlin("jvm") version "2.4.21"
    `jvm-test-suite`
}

repositories {
    mavenCentral()
}

val integrationTestSourceSet = sourceSets.create("integrationTest") {
    kotlin.srcDir("src/integration-test/kotlin")
    resources.srcDir("src/integration-test/resources")
    compileClasspath += sourceSets.main.get().output + configurations.testRuntimeClasspath.get()
    runtimeClasspath += output + compileClasspath
}

dependencies {
    implementation(kotlin("stdlib"))
    testImplementation("org.assertj:assertj-core:3.27.7")
    testImplementation("org.junit.jupiter:junit-jupiter-api:6.0.3")
    testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:6.0.3")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

val integrationTest = tasks.register<Test>("integrationTest") {
    description = "Runs integration tests."
    group = "verification"
    testClassesDirs = integrationTestSourceSet.output.classesDirs
    classpath = integrationTestSourceSet.runtimeClasspath
    mustRunAfter(tasks.named("test"))
    useJUnitPlatform {
        includeTags("integrationTest")
    }
}

tasks.named("check") {
    dependsOn(integrationTest)
}

tasks.named<Test>("test") {
    useJUnitPlatform {
        includeTags("unitTest")
    }
}

Agora temos um build Gradle completo que compila e executa nossos testes unitários e de integração. A seguir, veremos como escrever os testes unitários e de integração que serão executados pelo Gradle.

Escrevendo Testes Unitários e de Integração

Podemos escrever nossos testes unitários e de integração seguindo estes passos:

Primeiro, como já escrevemos nossa classe de teste unitário anteriormente, a única coisa que precisamos fazer é adicionar a anotação @Tag("unitTest") à nossa classe de teste unitário (localizada em src/test/kotlin).

Após adicionarmos essa tag à nossa classe de teste unitário, seu código-fonte ficará assim:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Test

@Tag("unitTest")
class MessageServiceTest {

    private lateinit var service: MessageService

    @BeforeEach
    fun configureSystemUnderTest() {
        service = MessageService()
    }

    @Test
    @DisplayName("Should return the correct message")
    fun shouldReturnCorrectMessage() {
        val message = service.getMessage()
        assertThat(message).isEqualTo("Hello World!")
    }
}

Segundo, devemos escrever uma classe de teste de integração (localizada em src/integration-test/kotlin) que garanta que o sistema sob teste retorne a mensagem correta. Além disso, devemos adicionar a anotação @Tag("integrationTest") à nossa classe de teste de integração.

Depois de escrevermos nossa classe de teste de integração, seu código-fonte ficará da seguinte maneira:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Test

@Tag("integrationTest")
class GetMessageTest {

    private lateinit var service: MessageService

    @BeforeEach
    fun configureSystemUnderTest() {
        service = MessageService()
    }

    @Test
    @DisplayName("Should return the correct message")
    fun shouldReturnCorrectMessage() {
        val message = service.getMessage()
        assertThat(message).isEqualTo("Hello World!")
    }
}

Leitura Adicional:

Agora já escrevemos nossos testes unitários e de integração. Vamos em frente e descobrir como executá-los no terminal.

Executando Nossos Testes

Se quisermos executar apenas os testes unitários, devemos rodar o comando: ./gradlew clean test. Quando executamos esse comando no terminal, vemos que o Gradle compila e executa apenas os nossos testes unitários:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
> Task :clean
> Task :compileKotlin
> Task :compileJava NO-SOURCE
> Task :processResources NO-SOURCE
> Task :classes UP-TO-DATE
> Task :compileTestKotlin
> Task :compileTestJava NO-SOURCE
> Task :processTestResources NO-SOURCE
> Task :testClasses UP-TO-DATE
> Task :test

Se quisermos executar apenas os testes de integração, devemos rodar o comando: ./gradlew clean integrationTest. Quando executamos esse comando no terminal, vemos que o Gradle compila e executa apenas os nossos testes de integração:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
> Task :clean
> Task :compileKotlin
> Task :compileJava NO-SOURCE
> Task :processResources NO-SOURCE
> Task :classes UP-TO-DATE
> Task :compileIntegrationTestKotlin
> Task :compileIntegrationTestJava NO-SOURCE
> Task :processIntegrationTestResources NO-SOURCE
> Task :integrationTestClasses UP-TO-DATE
> Task :integrationTest

Se quisermos executar tanto os testes unitários quanto os testes de integração, podemos rodar um destes dois comandos: ./gradlew clean test integrationTest ou ./gradlew clean build. Quando executamos a task build, vemos que o Gradle executa primeiro os testes unitários e, em seguida, os testes de integração:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
> Task :clean
> Task :compileKotlin
> Task :compileJava NO-SOURCE
> Task :processResources NO-SOURCE
> Task :classes UP-TO-DATE
> Task :jar
> Task :assemble
> Task :compileTestKotlin
> Task :compileTestJava NO-SOURCE
> Task :processTestResources NO-SOURCE
> Task :testClasses UP-TO-DATE
> Task :test
> Task :compileIntegrationTestKotlin
> Task :compileIntegrationTestJava NO-SOURCE
> Task :processIntegrationTestResources NO-SOURCE
> Task :integrationTestClasses UP-TO-DATE
> Task :integrationTest
> Task :check
> Task :build

Agora sabemos como criar um build Gradle moderno que compila e executa testes unitários e de integração utilizando Kotlin e JUnit Jupiter. Vamos resumir o que aprendemos nesta aula.

Resumo

Esta aula nos ensinou cinco pontos fundamentais:

  • Como estamos utilizando a JVM, devemos aplicar o plugin Gradle do Kotlin JVM (kotlin("jvm")).
  • Declaramos a biblioteca padrão do Kotlin (kotlin-stdlib) na configuração de dependências implementation e as bibliotecas de teste (junit-jupiter-api, assertj-core) em testImplementation.
  • Podemos adicionar novos diretórios de código-fonte e recursos de teste (src/integration-test/kotlin e src/integration-test/resources) ao nosso build Gradle configurando source sets dedicados e tasks do tipo Test.
  • Para executar testes que utilizam o JUnit Jupiter (JUnit 6), precisamos habilitar o suporte nativo à JUnit Platform no Gradle com useJUnitPlatform().
  • Podemos filtrar com segurança quais testes são invocados em cada fase do build utilizando tags do JUnit (@Tag e includeTags).