Testes Parametrizados com Kotlin e JUnit 6: Parte 2

Ao concluir esta aula do webinar sobre Kotlin 2.4.21, JUnit Jupiter 6.0.3, AssertJ 3.27.7 e Spring Boot 4.1.2 (com Gradle 9.8.2 Kotlin DSL e MockK 1.14), nós:

  • Sabemos utilizar os mecanismos de conversão de argumentos fornecidos pelo JUnit 6.
  • Sabemos como agregar os parâmetros de função das nossas funções de teste parametrizadas.
  • Compreendemos como podemos utilizar testes parametrizados ao escrever testes para aplicações do mundo real.

Vamos começar convertendo nossos dados de teste em diferentes objetos.

Esta aula assume que:

  • Você sabe escrever classes de teste com JUnit 6 e Kotlin (01-escrevendo-classes-de-teste-com-kotlin-e-junit6)
  • Você sabe escrever testes parametrizados simples com Kotlin e JUnit 6 (04-testes-parametrizados-com-kotlin-e-junit6-parte1)

Convertendo Nossos Dados de Teste em Diferentes Objetos

O JUnit 6 (JUnit Jupiter 6.0.3) possui um suporte muito versátil para conversão de argumentos. Para ser mais específico, ele suporta a conversão de alargamento de tipos primitivos (widening primitive conversion) e conta com diversos conversores de tipo implícitos embutidos (built-in implicit type converters) capazes de converter objetos String nos objetos que são passados para a nossa função de teste parametrizada. O conversor de tipo utilizado depende do tipo declarado em cada parâmetro da função.

Como o guia do usuário do JUnit Jupiter fornece uma excelente documentação sobre o processo de conversão implícita de tipos, não o descreverei em detalhes durante esta aula.

Leitura Adicional:

A seguir, descobriremos como podemos criar o parâmetro de função do nosso teste parametrizado utilizando uma função de fábrica (factory function) ou um construtor de fábrica (factory constructor).

Usando a Conversão de Fallback de String para Objeto

O JUnit 6 possui um mecanismo que fornece uma conversão automática de um objeto String para o tipo de destino solicitado (Fallback String-to-Object Conversion). Podemos utilizar esse mecanismo se o tipo de destino possuir exatamente uma função de fábrica adequada ou um construtor de fábrica adequado.

Vamos analisar dois exemplos que demonstram como podemos utilizar esse mecanismo.

Exemplo 1: Usando uma Função de Fábrica (Factory Function)

Se quisermos criar nosso parâmetro de função utilizando uma função de fábrica, o tipo de destino deve possuir uma função não privada e static (anotada com @JvmStatic no companion object em Kotlin) que receba um único objeto String como parâmetro de função e retorne uma instância do tipo de destino.

Por exemplo, se quisermos passar objetos Book para a nossa função de teste parametrizada, o código-fonte da classe Book deve ficar da seguinte forma:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class Book {

    companion object {

        @JvmStatic
        fun fromTitle(title: String): Book {
            return Book().apply {
                this.title = title
            }
        }
    }

    lateinit var title: String
}

Agora podemos escrever nossa função de teste parametrizada seguindo estes passos:

  1. Adicione uma nova função de teste parametrizada à nossa classe de teste e garanta que ela receba um objeto Book como parâmetro de função.
  2. Especifique os nossos dados de teste.
  3. Garanta que o objeto Book recebido como parâmetro de função possua o título correto.

Após escrevermos nosso teste parametrizado, o código-fonte da nossa classe de teste ficará da seguinte forma:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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.ValueSource

@Tag("unitTest")
@DisplayName("Factory function conversion example")
class FactoryFunctionConversionExampleTest {

    @ParameterizedTest
    @DisplayName("Should use the factory function")
    @ValueSource(strings = ["Moby Dick"])
    fun shouldCreateFunctionParameterWithFactoryFunction(book: Book) {
        assertThat(book.title).isEqualTo("Moby Dick")
    }
}

Exemplo 2: Usando um Construtor de Fábrica (Factory Constructor)

Se quisermos criar nosso parâmetro de função utilizando um construtor de fábrica, o tipo de destino deve possuir um construtor não privado que receba um único objeto String como argumento do construtor.

Por exemplo, se quisermos passar objetos Movie para a nossa função de teste parametrizada, o código-fonte da classe Movie deve ficar da seguinte forma:

1
data class Movie(val title: String)

Agora podemos escrever nossa função de teste parametrizada seguindo estes passos:

  1. Adicione uma nova função de teste parametrizada à nossa classe de teste e garanta que ela receba um objeto Movie como parâmetro de função.
  2. Especifique os nossos dados de teste.
  3. Garanta que o objeto Movie recebido como parâmetro de função possua o título correto.

Após escrevermos nosso teste parametrizado, o código-fonte da nossa classe de teste ficará da seguinte forma:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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.ValueSource

@Tag("unitTest")
@DisplayName("Factory constructor conversion example")
class FactoryConstructorConversionExampleTest {

    @ParameterizedTest
    @DisplayName("Should use the factory constructor")
    @ValueSource(strings = ["The Godfather"])
    fun shouldCreateFunctionParameterWithFactoryConstructor(movie: Movie) {
        assertThat(movie.title).isEqualTo("The Godfather")
    }
}

Há dois pontos importantes que quero destacar:

  • Se o tipo de destino possuir múltiplas funções de fábrica, essas funções de fábrica serão ignoradas pelo JUnit 6.
  • Se o tipo de destino possuir tanto uma função de fábrica quanto um construtor de fábrica, o JUnit 6 utilizará a função de fábrica em vez do construtor.

Leitura Adicional:

Vamos avançar e descobrir como podemos utilizar um ArgumentConverter customizado.

Usando um ArgumentConverter Customizado

Se não pudermos utilizar os mecanismos de conversão de argumentos embutidos fornecidos pelo JUnit 6 e precisarmos converter um único objeto String em outro objeto, sempre podemos implementar um ArgumentConverter customizado e criar o parâmetro de função da nossa função de teste parametrizada utilizando o nosso novo ArgumentConverter.

Vamos supor que queremos passar objetos Album para a nossa função de teste parametrizada. O código-fonte da classe Album é o seguinte:

1
data class Album(val title: String)

Se quisermos utilizar um ArgumentConverter customizado, devemos seguir estes passos:

Primeiro, devemos implementar um ArgumentConverter customizado seguindo estes passos:

  1. Crie uma nova classe que estenda a classe SimpleArgumentConverter.
  2. Sobrescreva a função convert() da classe SimpleArgumentConverter.
  3. Implemente a função convert() retornando um novo objeto Album. Além disso, se o tipo de destino não for Album, lance uma nova IllegalArgumentException.

Após escrevermos nosso conversor de argumentos customizado, seu código-fonte ficará da seguinte forma:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import org.junit.jupiter.params.converter.SimpleArgumentConverter
import java.lang.IllegalArgumentException

class AlbumArgumentConverter : SimpleArgumentConverter() {

    override fun convert(source: Any?, targetType: Class<*>?): Any {
        if (targetType != Album::class.java)
            throw IllegalArgumentException("Unknown target type")

        return Album(source.toString())
    }
}

Segundo, devemos escrever nossa função de teste parametrizada seguindo estes passos:

  1. Adicione uma nova função de teste parametrizada à nossa classe de teste e garanta que ela receba um objeto Album como parâmetro de função.
  2. Garanta que o objeto Album seja criado pela classe AlbumArgumentConverter.
  3. Especifique os nossos dados de teste.
  4. Garanta que o objeto Album recebido como parâmetro de função possua o título correto.

Após escrevermos nosso teste parametrizado, o código-fonte da nossa classe de teste ficará da seguinte forma:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
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.converter.ConvertWith
import org.junit.jupiter.params.provider.ValueSource

@Tag("unitTest")
@DisplayName("Argument converter example")
class ArgumentConverterExampleTest {

    @ParameterizedTest
    @DisplayName("Should use the specified argument converter")
    @ValueSource(strings = ["Yellow Submarine"])
    fun shouldCreateFunctionParameterWithArgumentConverter(
        @ConvertWith(AlbumArgumentConverter::class) album: Album
    ) {
        assertThat(album.title).isEqualTo("Yellow Submarine")
    }
}

Leitura Adicional:

Agora já estamos familiarizados com os diferentes mecanismos de conversão de argumentos fornecidos pelo JUnit 6. A seguir, aprenderemos a agregar os parâmetros de função das nossas funções de teste parametrizadas.

Agregando Múltiplos Argumentos em um Único Objeto

Se utilizarmos uma fonte de argumentos que suporte múltiplos argumentos, o comportamento padrão do JUnit 6 assume que cada argumento fornecido à nossa função de teste parametrizada corresponde a um parâmetro de função individual. Em outras palavras, se fornecermos um grande número de argumentos para a nossa função de teste, a sua assinatura também se tornará extensa.

Podemos resolver esse problema utilizando a classe ArgumentsAccessor ou escrevendo um ArgumentsAggregator customizado. Vamos avançar e descobrir como utilizar essas técnicas.

Exemplo 1: Usando a Classe ArgumentsAccessor

Se quisermos utilizar a classe ArgumentsAccessor, devemos escrever nosso teste parametrizado seguindo estes passos:

  1. Adicione uma nova função de teste parametrizada à nossa classe de teste e garanta que ela receba um objeto ArgumentsAccessor como parâmetro de função.
  2. Configure os dados de teste.
  3. Crie um novo objeto Person utilizando o objeto ArgumentsAccessor recebido como parâmetro de função.
  4. Garanta que o objeto Person criado possua o id e o name corretos.

Após escrevermos nosso teste parametrizado, o código-fonte da nossa classe de teste 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
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.aggregator.ArgumentsAccessor
import org.junit.jupiter.params.provider.CsvSource

@Tag("unitTest")
@DisplayName("ArgumentsAccessor example")
class ArgumentsAccessorExampleTest {

    @ParameterizedTest
    @DisplayName("Should use the ArgumentsAccessor")
    @CsvSource("1, John Doe")
    fun shouldUseArgumentsAccessor(arguments: ArgumentsAccessor) {
        val person = Person(arguments.getLong(0), arguments.getString(1))

        assertThat(person.id).isEqualByComparingTo(1L)
        assertThat(person.name).isEqualTo("John Doe")
    }
}

A classe Person é uma data class simples que contém o id e o name de uma pessoa. Seu código-fonte é o seguinte:

1
data class Person(val id: Long, val name: String)

Há três pontos que quero destacar:

  • A classe ArgumentsAccessor utiliza um índice baseado em zero (zero-based index).
  • A classe ArgumentsAccessor suporta os mecanismos embutidos de conversão de argumentos fornecidos pelo JUnit 6.
  • A classe ArgumentsAccessor é uma boa escolha se quisermos simplesmente reduzir o número de parâmetros da função e não precisarmos converter nossos dados de teste em um objeto específico de forma reutilizável (como um objeto Person em vários testes).

Leitura Adicional:

Exemplo 2: Usando um ArgumentsAggregator Customizado

Se quisermos fornecer objetos Person para a nossa função de teste parametrizada utilizando um ArgumentsAggregator customizado, devemos seguir estes passos:

Primeiro, devemos criar uma classe ArgumentsAggregator customizada seguindo estes passos:

  1. Crie uma classe que implemente a interface ArgumentsAggregator.
  2. Sobrescreva a função aggregateArguments().
  3. Implemente a função aggregateArguments() retornando um novo objeto Person. Além disso, se o número de argumentos não for igual a dois, lance uma nova IllegalArgumentException.

Após escrevermos nossa classe ArgumentsAggregator customizada, seu código-fonte ficará da seguinte forma:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import org.junit.jupiter.api.extension.ParameterContext
import org.junit.jupiter.params.aggregator.ArgumentsAccessor
import org.junit.jupiter.params.aggregator.ArgumentsAggregator
import java.lang.IllegalArgumentException

class PersonAggregator : ArgumentsAggregator {

    override fun aggregateArguments(
        arguments: ArgumentsAccessor,
        context: ParameterContext
    ): Any {
        if (arguments.size() != 2)
            throw IllegalArgumentException("Unsupported argument count")
        return Person(arguments.getLong(0), arguments.getString(1))
    }
}

Segundo, devemos escrever nossa função de teste parametrizada seguindo estes passos:

  1. Adicione uma nova função de teste parametrizada à nossa classe de teste e garanta que ela receba um objeto Person como parâmetro de função.
  2. Garanta que o objeto Person seja criado pela classe PersonAggregator.
  3. Especifique os nossos dados de teste.
  4. Garanta que o objeto Person recebido como parâmetro do método possua o id e o name corretos.

Após escrevermos nossa função de teste parametrizada, seu código-fonte 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
import com.testspringmaster.kotlin.example.Person
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.aggregator.AggregateWith
import org.junit.jupiter.params.provider.CsvSource

@Tag("unitTest")
@DisplayName("PersonAggregator example")
class PersonAggregatorExampleTest {

    @ParameterizedTest
    @DisplayName("Should pass a Person object as function parameter")
    @CsvSource("1, John Doe")
    fun shouldPassPersonObjectAsFunctionParameter(
        @AggregateWith(PersonAggregator::class) person: Person
    ) {
        assertThat(person.id).isEqualByComparingTo(1L)
        assertThat(person.name).isEqualTo("John Doe")
    }
}

Leitura Adicional:

Agora já sabemos como agregar os parâmetros de função dos nossos testes parametrizados. Vamos avançar e descobrir como podemos tirar proveito dos testes parametrizados ao escrever testes para aplicações do mundo real.

Testes Parametrizados — Além do Básico

Não tenho visto muitos testes parametrizados no dia a dia de trabalho e, infelizmente, preciso confessar que também não costumava escrevê-los com tanta frequência. Isso é uma pena, pois o teste parametrizado é uma ferramenta extremamente útil quando estamos escrevendo testes para métodos de busca (finder methods).

Por exemplo, vamos supor que precisamos escrever testes para a classe PersonRepository. Essa classe possui um método chamado findById() que retorna as informações da pessoa encontrada. Se nenhuma pessoa for encontrada com o id fornecido, esse método lança uma NotFoundException.

A seguir, aprenderemos a tirar proveito dos testes parametrizados ao escrever testes para a classe PersonRepository. Quando quisermos escrever testes para o método findById(), devemos seguir estes passos:

Primeiro, devemos adicionar uma nova inner class à nossa classe de teste. Essa classe conterá os métodos de teste que garantem que o método findById() esteja funcionando conforme o esperado.

Após adicionarmos essa inner class à nossa classe de teste, o código-fonte da nossa classe de teste ficará da seguinte forma:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Tag

@Tag("unitTest")
@DisplayName("A parameterized test for a finder method")
class PersonRepositoryTest {

    private val repository = PersonRepository()

    @Nested
    @DisplayName("Find person by id")
    inner class FindById {

    }
}

Segundo, devemos escrever um método de teste que garanta que o método findById() lance uma NotFoundException quando a pessoa solicitada não for encontrada.

Após escrevermos esse método de teste, o código-fonte da nossa classe de teste 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
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.catchThrowable
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Test

@Tag("unitTest")
@DisplayName("A parameterized test for a finder method")
class PersonRepositoryTest {

    private val repository = PersonRepository()

    @Nested
    @DisplayName("Find person by id")
    inner class FindById {

        @Nested
        @DisplayName("When the person is not found")
        inner class WhenPersonIsNotFound {

            private val ID_NOT_FOUND = 999L

            @Test
            @DisplayName("Should throw an exception")
            fun shouldThrowException() {
                val thrown = catchThrowable {
                    repository.findById(ID_NOT_FOUND)
                }
                assertThat(thrown)
                    .isExactlyInstanceOf(NotFoundException::class.java)
            }
        }
    }
}

Terceiro, devemos escrever um teste parametrizado que garanta que o método findById() retorne as informações da pessoa encontrada.

Após escrevermos esse teste parametrizado, o código-fonte da nossa classe de teste 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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import com.testwithspring.master.kotlin.aggregation.PersonAggregator
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.catchThrowable
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.aggregator.AggregateWith
import org.junit.jupiter.params.provider.CsvSource

@Tag("unitTest")
@DisplayName("A parameterized test for a finder method")
class PersonRepositoryTest {

    private val repository = PersonRepository()

    @Nested
    @DisplayName("Find person by id")
    inner class FindById {

        @Nested
        @DisplayName("When the person is not found")
        inner class WhenPersonIsNotFound {

            private val ID_NOT_FOUND = 999L

            @Test
            @DisplayName("Should throw an exception")
            fun shouldThrowException() {
                val thrown = catchThrowable {
                    repository.findById(ID_NOT_FOUND)
                }
                assertThat(thrown)
                    .isExactlyInstanceOf(NotFoundException::class.java)
            }
        }

        @Nested
        @DisplayName("When the person is found")
        inner class WhenPersonIsFound {

            @ParameterizedTest
            @DisplayName("Should return the found person")
            @CsvSource(
                "1, John Doe",
                "2, Jane Doe"
            )
            fun shouldReturnFoundPerson(
                @AggregateWith(PersonAggregator::class) person: Person
            ) {
                val found = repository.findById(person.id)

                assertThat(found.id).isEqualByComparingTo(person.id)
                assertThat(found.name).isEqualTo(person.name)
            }
        }
    }
}

Como podemos ver, ao combinarmos testes parametrizados com métodos de teste convencionais, torna-se bastante simples escrever testes limpos e abrangentes para métodos de busca. Essa abordagem é especialmente útil se o sistema sob teste utilizar uma lógica de mapeamento complexa para criar o objeto retornado e quisermos escrever testes para essa lógica.

Naturalmente, se utilizarmos testes parametrizados para essa finalidade, é uma excelente ideia utilizar soft assertions (asserções suaves do AssertJ), pois elas nos permitem coletar todos os erros de asserção e exibi-los de uma só vez quando um caso de teste falha.

Além disso, embora este exemplo invoque um repositório em memória codificado diretamente (hard-coded), podemos aplicar exatamente essa mesma técnica ao escrever testes de integração com o Spring Test Framework no Spring Boot 4.1.2.

Vamos resumir o que aprendemos nesta aula.

Resumo

Esta aula nos ensinou quatro coisas fundamentais:

  • O JUnit 6 possui um mecanismo que fornece uma conversão automática de um objeto String para o tipo de destino solicitado caso o tipo de destino possua exatamente uma função de fábrica adequada ou um construtor de fábrica adequado.
  • Se não pudermos utilizar os mecanismos de conversão de argumentos embutidos fornecidos pelo JUnit 6 e precisarmos converter um único objeto String em outro objeto, sempre podemos implementar um ArgumentConverter customizado.
  • Se quisermos combinar múltiplos argumentos em um único parâmetro de função, devemos utilizar a classe ArgumentsAccessor ou implementar um ArgumentsAggregator customizado.
  • Se combinarmos testes parametrizados com métodos de teste normais, torna-se muito simples escrever testes limpos e abrangentes para métodos de busca (finder methods).