Dominando Testes Parametrizados no JUnit 6

Depois de concluirmos esta aula, nós:

  • Saberemos como adicionar as dependências necessárias do JUnit 6 para testes parametrizados com Gradle e Maven.
  • Saberemos como escrever testes parametrizados simples usando a anotação @ParameterizedTest.
  • Saberemos como customizar o nome de exibição (display name) de cada invocação do nosso teste parametrizado.
  • Compreenderemos como utilizar todas as diferentes fontes de argumentos (argument sources) fornecidas pelo JUnit Jupiter: @ValueSource, @EnumSource, @CsvSource, @CsvFileSource, @MethodSource e @ArgumentsSource.

Vamos começar obtendo as dependências necessárias.


1. Obtendo as Dependências Necessárias

Para escrever testes parametrizados com JUnit 6 (JUnit Jupiter 6.0.3), precisamos declarar a dependência junit-jupiter-params no nosso build:

  • Name: JUnit Jupiter Params
  • Group ID: org.junit.jupiter
  • Artifact ID: junit-jupiter-params
  • Version: 6.0.3 (gerenciada automaticamente pelo BOM junit-bom do Spring Boot 4.1.2)

Com Gradle 9.8.2 (Kotlin DSL)

Se estivermos usando o plugin jvm-test-suite ou o spring-boot-starter-test, o artefato junit-jupiter-params já vem incluído transitivamente por meio do artefato agregador org.junit.jupiter:junit-jupiter. Caso você configure as dependências do JUnit manualmente no build.gradle.kts, certifique-se de adicioná-lo no escopo testImplementation:

1
2
3
4
5
6
dependencies {
    testImplementation(platform("org.junit:junit-bom:6.0.3"))
    testImplementation("org.junit.jupiter:junit-jupiter-api")
    testImplementation("org.junit.jupiter:junit-jupiter-params")
    testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
}

Com Maven

Se você utiliza Maven com junit-bom, adicione a dependência junit-jupiter-params com escopo test:

1
2
3
4
5
6
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-params</artifactId>
    <version>6.0.3</version>
    <scope>test</scope>
</dependency>

A seguir, vamos escrever nosso primeiro teste parametrizado com JUnit 6.


2. Escrevendo Nosso Primeiro Teste Parametrizado

Para criar um teste parametrizado simples com JUnit Jupiter, precisamos seguir dois passos:

  1. Anotar o método de teste com @ParameterizedTest (em vez de @Test).
  2. Configurar a fonte de dados que fornece os parâmetros do método usando uma anotação de argument source. Neste primeiro exemplo, passaremos duas Strings ("Hello" e "World") utilizando a anotação @ValueSource:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
package com.example.junit6

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource

@DisplayName("Passando os parâmetros do método fornecidos pela anotação @ValueSource")
class ValueSourceExampleTest {

    @DisplayName("Deve passar uma mensagem não nula para nosso método de teste")
    @ParameterizedTest
    @ValueSource(strings = ["Hello", "World"])
    fun shouldPassNonNullMessageAsMethodParameter(message: String) {
        assertThat(message).isNotNull()
    }
}

Quando executamos este teste na IDE ou no terminal, vemos que o JUnit 6 invoca nosso método de teste duas vezes — uma para cada elemento do array strings — e exibe cada invocação individualmente no relatório:

1
2
3
4
Passando os parâmetros do método fornecidos pela anotação @ValueSource
 └─ Deve passar uma mensagem não nula para nosso método de teste
     ├─ [1] message = Hello
     └─ [2] message = World

Como podemos notar na saída acima, o nome de exibição padrão de cada invocação contém o índice da invocação ([1], [2]) e a representação String de todos os parâmetros passados ao método. Contudo, podemos customizar esse formato facilmente.


3. Customizando o Nome de Exibição de Cada Invocação

Podemos customizar o nome de exibição (display name) de cada invocação configurando o atributo name da anotação @ParameterizedTest. Dentro dessa String de formatação, podemos utilizar os seguintes placeholders:

  • {index}: é substituído pelo índice da invocação atual. O índice da primeira invocação é 1, o da segunda é 2, e assim por diante.
  • {arguments}: é substituído pela lista completa de argumentos separados por vírgula passados ao método de teste.
  • {0}, {1}, …: é substituído pelo valor de um argumento individual na posição correspondente (onde {0} é o primeiro argumento, {1} é o segundo, etc.).

[!IMPORTANT] Atenção às aspas simples no MessageFormat

Internamente, o JUnit Jupiter utiliza a classe java.text.MessageFormat para formatar o nome de exibição da invocação. No MessageFormat, uma aspa simples (') é tratada como caractere de escape! Portanto, se você quiser exibir uma aspa simples literal no nome do seu teste, precisará escrever duas aspas simples consecutivas ('').

Vamos atualizar nosso teste anterior para exibir o formato "{index} => message=''{0}''":

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

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource

@DisplayName("Passando os parâmetros do método fornecidos pela anotação @ValueSource")
class ValueSourceExampleTest {

    @DisplayName("Deve passar uma mensagem não nula para nosso método de teste")
    @ParameterizedTest(name = "{index} => message=''{0}''")
    @ValueSource(strings = ["Hello", "World"])
    fun shouldPassNonNullMessageAsMethodParameter(message: String) {
        assertThat(message).isNotNull()
    }
}

Ao executar novamente o teste, o relatório mostrará nossas invocações com o template customizado:

1
2
3
4
Passando os parâmetros do método fornecidos pela anotação @ValueSource
 └─ Deve passar uma mensagem não nula para nosso método de teste
     ├─ 1 => message='Hello'
     └─ 2 => message='World'

Agora que sabemos como criar e formatar um teste parametrizado, vamos explorar todas as fontes de argumentos disponíveis no JUnit 6.


4. Especificando as Fontes de Argumentos (Argument Sources)

O módulo junit-jupiter-params oferece diversas anotações para fornecer dados aos nossos testes parametrizados. Vamos analisar cada uma delas em detalhes.

4.1. @ValueSource

A anotação @ValueSource é a fonte de argumentos mais simples: ela permite configurar um único array de valores literais e só pode ser utilizada em testes parametrizados que recebem exatamente um argumento por invocação.

Ela suporta arrays de:

  • shorts (ShortArray)
  • bytes (ByteArray)
  • ints (IntArray)
  • longs (LongArray)
  • floats (FloatArray)
  • doubles (DoubleArray)
  • chars (CharArray)
  • booleans (BooleanArray)
  • strings (Array<String>)
  • classes (Array<KClass<*>>)

[!NOTE] Complementarmente à @ValueSource, o JUnit Jupiter também fornece as anotações @NullSource, @EmptySource e @NullAndEmptySource para injetar valores null e strings/coleções vazias sem esforço adicional.

4.2. @EnumSource

A anotação @EnumSource permite passar valores de um enum para um teste parametrizado que recebe um único objeto Enum como argumento.

Considere o seguinte enum Pet:

1
2
3
4
5
6
package com.example.junit6

enum class Pet {
    CAT,
    DOG
}

Se quisermos executar nosso método de teste passando todos os valores disponíveis no enum Pet, basta anotar o método com @EnumSource(Pet::class) (no JUnit 6 em Kotlin, você pode inclusive omitir Pet::class se o tipo do parâmetro do método já for Pet, pois o JUnit infere o tipo automaticamente!):

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

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.EnumSource

@DisplayName("Passando valores de enum para nosso método de teste")
class EnumSourceExampleTest {

    @DisplayName("Deve passar valores de enum não nulos como parâmetros do método")
    @ParameterizedTest(name = "{index} => pet=''{0}''")
    @EnumSource(Pet::class)
    fun shouldPassNonNullEnumValuesAsMethodParameter(pet: Pet) {
        assertThat(pet).isNotNull()
    }
}

Se quisermos restringir a execução do nosso teste para apenas valores específicos do enum Pet, podemos configurar o atributo names da anotação @EnumSource:

1
2
3
4
5
6
    @DisplayName("Deve passar apenas o valor especificado do enum como parâmetro do método")
    @ParameterizedTest(name = "{index} => pet=''{0}''")
    @EnumSource(value = Pet::class, names = ["CAT"])
    fun shouldPassNonNullEnumValueAsMethodParameter(pet: Pet) {
        assertThat(pet).isEqualTo(Pet.CAT)
    }

4.3. @CsvSource

Quando precisamos passar múltiplos argumentos para o nosso método de teste (como entradas e o resultado esperado de um cálculo), @ValueSource e @EnumSource não são suficientes. Nesses cenários, podemos usar @CsvSource.

Com @CsvSource, cada String do array representa uma linha CSV completa (uma invocação do método de teste), onde os valores separados por vírgula são convertidos automaticamente pelo JUnit para os tipos dos parâmetros do método (Int, Long, String, Boolean, LocalDate, etc.).

Vamos escrever um teste parametrizado que verifica se o método sum() calcula corretamente a soma de dois números inteiros:

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

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource

@DisplayName("Deve passar os parâmetros fornecidos pela anotação @CsvSource")
class CsvSourceExampleTest {

    @DisplayName("Deve calcular a soma correta")
    @ParameterizedTest(name = "{index} => a={0}, b={1}, sum={2}")
    @CsvSource(
        "1, 1, 2",
        "2, 3, 5"
    )
    fun sum(a: Int, b: Int, sum: Int) {
        assertThat(a + b).isEqualTo(sum)
    }
}

[!TIP] Se você precisar passar uma String que contenha vírgulas dentro do @CsvSource, basta envolvê-la em aspas simples, por exemplo: "1, 'Tarefa, urgente', true". Você também pode usar delimiter = ';' caso prefira separar colunas por ponto e vírgula.

4.4. @CsvFileSource

Se a tabela de casos de teste for extensa, embutir dezenas de linhas dentro do código-fonte com @CsvSource prejudica a leitura. Para resolver isso, podemos armazenar os dados em um arquivo .csv no classpath (por exemplo, src/test/resources/test-data.csv) e carregá-lo com @CsvFileSource.

Suponha que o arquivo src/test/resources/test-data.csv tenha o seguinte conteúdo:

1
2
1, 1, 2
2, 3, 5

Podemos configurar nosso teste parametrizado apontando o atributo resources da anotação @CsvFileSource para /test-data.csv:

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

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvFileSource

@DisplayName("Deve passar os parâmetros fornecidos pelo arquivo em @CsvFileSource")
class CsvFileSourceExampleTest {

    @DisplayName("Deve calcular a soma correta a partir do arquivo CSV")
    @ParameterizedTest(name = "{index} => a={0}, b={1}, sum={2}")
    @CsvFileSource(resources = ["/test-data.csv"])
    fun sum(a: Int, b: Int, sum: Int) {
        assertThat(a + b).isEqualTo(sum)
    }
}

[!NOTE] Se o seu arquivo CSV possuir uma linha de cabeçalho na primeira linha (ex: a, b, sum), você pode instruir o JUnit a ignorá-la adicionando numLinesToSkip = 1 na anotação @CsvFileSource(resources = ["/test-data.csv"], numLinesToSkip = 1).

4.5. @MethodSource

As anotações anteriores funcionam muito bem para tipos primitivos, Strings e Enums, mas e quando precisamos passar objetos complexos de domínio, listas ou instâncias construídas dinamicamente? Para isso, utilizamos @MethodSource.

Com @MethodSource, especificamos o nome de um método factory que retorna um Stream, Iterable, Iterator ou array de instâncias de Arguments.

Regras importantes do método factory:

  1. Ele não deve aceitar argumentos.
  2. Cada elemento retornado deve ser uma instância de Arguments — criada convenientemente pelo método estático Arguments.of(a, b, c) — que contenha exatamente os objetos esperados pelos parâmetros do método de teste na mesma ordem.
  3. No JUnit, o método factory precisa ser estático (@JvmStatic dentro de um companion object em Kotlin), a menos que a classe de teste esteja anotada com @TestInstance(TestInstance.Lifecycle.PER_CLASS).

Vamos ver como implementar MethodSourceExampleTest em Kotlin usando companion object com @JvmStatic:

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

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.MethodSource
import java.util.stream.Stream

@DisplayName("Deve passar os parâmetros fornecidos pela anotação @MethodSource")
class MethodSourceExampleTest {

    @DisplayName("Deve calcular a soma correta")
    @ParameterizedTest(name = "{index} => a={0}, b={1}, sum={2}")
    @MethodSource("sumProvider")
    fun sum(a: Int, b: Int, sum: Int) {
        assertThat(a + b).isEqualTo(sum)
    }

    companion object {
        @JvmStatic
        private fun sumProvider(): Stream<Arguments> = Stream.of(
            Arguments.of(1, 1, 2),
            Arguments.of(2, 3, 5)
        )
    }
}

[!TIP] Se você não informar o nome do método na anotação @MethodSource (usando apenas @MethodSource()), o JUnit Jupiter procurará automaticamente por um método factory que tenha exatamente o mesmo nome do método de teste!

4.6. @ArgumentsSource

Se quisermos reutilizar a mesma lógica fornecedora de dados de teste em múltiplas classes de teste diferentes, podemos encapsulá-la em uma classe dedicada implementando a interface ArgumentsProvider e registrá-la com @ArgumentsSource.

Vamos fazer isso em dois passos:

Passo 1: Criar a classe CustomArgumentProvider que implementa ArgumentsProvider:

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

import org.junit.jupiter.api.extension.ExtensionContext
import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.ArgumentsProvider
import org.junit.jupiter.params.support.ParameterDeclarations
import java.util.stream.Stream

class CustomArgumentProvider : ArgumentsProvider {

    override fun provideArguments(
        parameters: ParameterDeclarations,
        context: ExtensionContext
    ): Stream<out Arguments> = Stream.of(
        Arguments.of(1, 1, 2),
        Arguments.of(2, 3, 5)
    )
}

Passo 2: Anotar nosso método de teste com @ArgumentsSource(CustomArgumentProvider::class):

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

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ArgumentsSource

@DisplayName("Deve passar os parâmetros fornecidos pela anotação @ArgumentsSource")
class ArgumentsSourceExampleTest {

    @DisplayName("Deve calcular a soma correta")
    @ParameterizedTest(name = "{index} => a={0}, b={1}, sum={2}")
    @ArgumentsSource(CustomArgumentProvider::class)
    fun sum(a: Int, b: Int, sum: Int) {
        assertThat(a + b).isEqualTo(sum)
    }
}

Resumo

Nesta aula bônus, dominamos os testes parametrizados no JUnit 6:

  • O artefato org.junit.jupiter:junit-jupiter-params fornece todo o suporte à parametrização no JUnit Jupiter.
  • Usamos @ParameterizedTest combinada com uma anotação de fonte de dados e podemos customizar o nome de cada execução usando {index}, {arguments} e {0}, {1}, etc. (lembrando de escapar aspas simples com '').
  • Para um único parâmetro simples, usamos @ValueSource ou @EnumSource.
  • Para múltiplos parâmetros tabulares, usamos @CsvSource (inline) ou @CsvFileSource (arquivo externo no classpath).
  • Para objetos complexos ou geração programática de dados, usamos @MethodSource (na própria classe) ou @ArgumentsSource com ArgumentsProvider (reutilizável entre várias classes).