Escrevendo Testes Parametrizados com JUnit 6

Ao concluirmos esta aula, nós:

  • Compreenderemos o que é um teste parametrizado e quando utilizá-lo.
  • Entenderemos como o antigo runner Parameterized do JUnit 4 funcionava e por que não devemos utilizar abordagens baseadas em injeção via construtor da classe de teste.
  • Seremos capazes de escrever testes parametrizados limpos e expressivos utilizando @ParameterizedTest e @CsvSource do módulo junit-jupiter-params no JUnit 6 (que incorporou nativamente a filosofia da biblioteca JUnitParams).

[!NOTE] Esta aula assume que:


O Que É um Teste Parametrizado?

Um teste parametrizado (parameterized test) é um caso de teste que é invocado múltiplas vezes utilizando diferentes conjuntos predefinidos de entrada (input) e saída esperada (expected output). Esses testes são tipicamente utilizados para testar classes ou funções puras de domínio que não possuem dependências externas e cujos métodos não possuem efeitos colaterais (como cálculos, validações, regras de preço, máquinas de estado ou conversores).

Vamos conhecer o Sistema Sob Teste que utilizaremos nesta aula.


Introdução ao Sistema Sob Teste (SUT)

Durante esta aula, escreveremos testes unitários para uma classe simples chamada Calculator. Ela possui um método chamado sum(), que retorna a soma de dois números inteiros (Int). O código-fonte da classe Calculator em Kotlin é o seguinte:

Calculator.kt
Arquivo Real KOTLIN
1
2
3
4
5
6
7
8
package com.testwithspring.starter.unittests

class Calculator {

    fun sum(first: Int, second: Int): Int {
        return first + second
    }
}

A seguir, vamos analisar primeiro como os testes parametrizados eram escritos no JUnit 4 padrão (Parameterized), entender seus problemas estruturais e, em seguida, ver como a biblioteca JUnitParams inspirou o módulo oficial junit-jupiter-params do JUnit 6!


Contexto Histórico: O Problema do Runner Parameterized do JUnit 4

No JUnit 4 padrão, escrever um teste parametrizado exigia cinco passos burocráticos:

  1. Anotar a classe de teste inteira com @RunWith(Parameterized::class).
  2. Criar um método estático anotado com @Parameters retornando uma Collection<Array<Any>> com os dados de entrada e saída esperada ({ 0, 0, 0 }, { 1, 1, 2 }).
  3. Declarar campos finais na classe de teste para armazenar o Calculator, first, second e expectedSum.
  4. Criar um construtor na classe de teste que recebia first, second e expectedSum para inicializar os campos.
  5. Escrever o método @Test lendo os campos da classe.

Veja a quantidade de código boilerplate que o runner Parameterized do JUnit 4 exigia para testar uma simples soma:

 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
// Contexto histórico: O verboso runner Parameterized do JUnit 4
@org.junit.runner.RunWith(org.junit.runners.Parameterized::class)
class LegacyStandardCalculatorTest(
    private val first: Int,
    private val second: Int,
    private val expectedSum: Int
) {
    private val calculator = Calculator()

    companion object {
        @JvmStatic
        @org.junit.runners.Parameterized.Parameters
        fun data(): Collection<Array<Any>> {
            return listOf(
                arrayOf(0, 0, 0),
                arrayOf(1, 1, 2)
            )
        }
    }

    @org.junit.Test
    fun shouldReturnCorrectSum() {
        val actualSum = calculator.sum(first, second)
        org.assertj.core.api.Assertions.assertThat(actualSum).isEqualByComparingTo(expectedSum)
    }
}

Embora essa abordagem funcionasse, ela tinha dois defeitos graves:

  • Exigia muito código boilerplate: declarar método estático de arrays de Object, campos de classe e construtor tornava o teste difícil de escrever e ler.
  • Amarrava a classe inteira a um único conjunto de parâmetros: Como os dados de teste eram passados pelo construtor da classe de teste, era impossível ter dois métodos de teste na mesma classe que recebessem parâmetros diferentes (por exemplo, um método para testar sum() e outro para testar multiply() ou sqrt())!

No JUnit 4, a comunidade resolvia esse problema adicionando a biblioteca externa pl.pragmatists:JUnitParams (@RunWith(JUnitParamsRunner.class) + @Parameters({ "0, 0, 0", "1, 1, 2" })), que permitia passar os parâmetros diretamente nos argumentos do método de teste!

Quando o JUnit 5 e o JUnit Jupiter 6 foram projetados, a equipe do JUnit absorveu a exata ideia do JUnitParams para dentro do pacote oficial org.junit.jupiter:junit-jupiter-params! Vamos ver como utilizá-lo agora.


Escrevendo Testes Parametrizados com junit-jupiter-params (e o Paralelo com JUnitParams)

1. Obtendo as Dependências Necessárias

Para escrever testes parametrizados no JUnit Jupiter 6, declaramos a dependência oficial junit-jupiter-params no nosso script de build (nota: se você já usa o agregador org.junit.jupiter:junit-jupiter ou spring-boot-starter-test, o junit-jupiter-params já está incluído automaticamente!).

Se estivermos declarando as dependências individualmente no Maven, adicionamos ao pom.xml:

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>

Se estivermos utilizando Gradle 9 (Kotlin DSL), adicionamos ao build.gradle.kts:

1
2
3
dependencies {
    testImplementation("org.junit.jupiter:junit-jupiter-params:6.0.3")
}

(Para comparação histórica: no JUnit 4 usava-se pl.pragmatists:JUnitParams:1.0.5).

Vamos agora reescrever nosso teste da Calculator de forma limpa passo a passo!


2. Escrevendo o Teste Unitário Parametrizado Passo a Passo

Podemos garantir que o método sum() da classe Calculator funciona como esperado seguindo estes passos:

Passo 1: Criar a classe de teste ParameterizedCalculatorTest

Primeiro, criamos nossa classe de teste. No JUnit 6, não precisamos de @RunWith(JUnitParamsRunner::class) — o motor Jupiter já suporta métodos parametrizados nativamente (inclusive dentro de classes @Nested!):

1
2
3
4
5
6
7
8
package com.testwithspring.starter.unittests

import org.junit.jupiter.api.Tag

@Tag("unitTest")
class ParameterizedCalculatorTest {

}

Passo 2: Escrever o método de teste recebendo os parâmetros diretamente

Segundo, adicionamos o método de teste à nossa classe de teste seguindo estes passos:

  1. Declaramos o método shouldReturnCorrectSum recebendo a entrada fornecida (first: Int, second: Int) e a saída esperada (expectedSum: Int) diretamente como parâmetros do método.
  2. Instanciamos Calculator, invocamos calculator.sum(first, second) e verificamos com o AssertJ se a soma retornada é igual a expectedSum.

Após escrevermos o método, o código fica assim:

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

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Tag

@Tag("unitTest")
class ParameterizedCalculatorTest {

    fun shouldReturnCorrectSum(
        first: Int,
        second: Int,
        expectedSum: Int
    ) {
        val calculator = Calculator()
        val actualSum = calculator.sum(first, second)
        assertThat(actualSum).isEqualByComparingTo(expectedSum)
    }
}

Passo 3: Configurar os parâmetros com @ParameterizedTest e @CsvSource

Terceiro, configuramos os parâmetros que serão passados para o nosso método de teste seguindo estes dois passos:

  1. Anotamos o método de teste com @ParameterizedTest (em vez de @Test).
  2. Anotamos o método com @CsvSource (o equivalente direto ao @Parameters({ "0, 0, 0", "1, 1, 2" }) do JUnitParams), especificando as linhas de entrada e saída esperada separadas por vírgula.

Após configurarmos os parâmetros do nosso teste parametrizado, o código-fonte completo da nossa classe de teste fica assim:

ParameterizedCalculatorTest.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
package com.testwithspring.starter.unittests

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

@Tag("unitTest")
@DisplayName("ParameterizedCalculatorTest - Testes Parametrizados com JUnit 6")
class ParameterizedCalculatorTest {

    @ParameterizedTest(name = "{index}: sum({0}, {1}) deve retornar {2}")
    @CsvSource(
        "0, 0, 0",
        "1, 1, 2"
    )
    @DisplayName("Deve retornar a soma correta para múltiplos pares de números")
    fun shouldReturnCorrectSum(
        first: Int,
        second: Int,
        expectedSum: Int
    ) {
        val calculator = Calculator()
        val actualSum = calculator.sum(first, second)
        assertThat(actualSum).isEqualTo(expectedSum)
    }
}

Quando executamos nossos testes com o Gradle, vemos cada iteração do teste parametrizado sendo executada de forma independente:

Evidência Real de Execução: ParameterizedCalculatorTest (@CsvSource)
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: 18240, address: [fe80::1%1:54340]).
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 :
Tasks to be executed: [task ':compileKotlin', task ':compileJava', task ':processResources', task ':classes', task ':compileTestKotlin', task ':compileTestJava', task ':processTestResources', task ':testClasses', task ':test']
Tasks that were skipped: [task ':compileJava', task ':compileTestJava']

> Task :compileKotlin UP-TO-DATE
> Task :processResources NO-SOURCE
> Task :classes UP-TO-DATE
> Task :compileTestKotlin UP-TO-DATE
> Task :processTestResources NO-SOURCE
> Task :testClasses UP-TO-DATE
> 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.unittests.ParameterizedCalculatorTest > 1: sum(0, 0) deve retornar 0 PASSED
com.testwithspring.starter.unittests.ParameterizedCalculatorTest > 2: sum(1, 1) deve retornar 2 PASSED

ParameterizedCalculatorTest > 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.015 secs) into: /home/wc/test-with-spring/code/01-starter-junit6/build/reports/tests/test

BUILD SUCCESSFUL in 950ms
5 actionable tasks: 1 executed, 4 up-to-date

Como podemos ver, @ParameterizedTest + @CsvSource (assim como o JUnitParams fazia no JUnit 4) resolve todos os problemas do antigo runner Parameterized:

  • Zero código boilerplate: não há construtor na classe de teste, nem propriedades intermediárias.
  • Parâmetros injetados diretamente no método: podemos ter múltiplos métodos @ParameterizedTest na mesma classe usando conjuntos de parâmetros completamente diferentes, e podemos misturar métodos @Test normais e classes @Nested na mesma classe de teste!
  • Fontes de dados ricas: além de @CsvSource, o junit-jupiter-params suporta nativamente leitura de arquivos CSV externos (@CsvFileSource), enums (@EnumSource), valores simples (@ValueSource), métodos provedores (@MethodSource) e classes provedoras customizadas (@ArgumentsSource) — que exploraremos ainda mais nas aulas avançadas!

Resumo

Nesta aula, aprendemos quatro pontos essenciais:

  • Não devemos usar o modelo antigo de testes parametrizados via construtor da classe de teste (Parameterized do JUnit 4), pois ele exige código boilerplate excessivo e impede múltiplos testes com assinaturas diferentes na mesma classe.
  • Para escrever testes parametrizados modernos no JUnit 6, utilizamos o módulo oficial org.junit.jupiter:junit-jupiter-params (que sucedeu a biblioteca JUnitParams).
  • Anotamos o método de teste com @ParameterizedTest e @CsvSource para injetar cada linha de dados de entrada e saída esperada diretamente nos parâmetros do método de teste.
  • Também podemos fornecer dados a partir de provedores customizados (@MethodSource, @ArgumentsSource) e fontes externas como arquivos CSV (@CsvFileSource).