Testes Orientados a Dados com withData no Kotest
Depois de concluirmos esta aula, nós:
- Compreenderemos por que devemos escrever testes parametrizados (orientados a dados) em vez de duplicar asserções no mesmo teste.
- Saberemos como escrever testes orientados a dados que utilizam um conjunto fixo de dados (data classes e
withData). - Saberemos como escrever testes orientados a dados que utilizam conjuntos dinâmicos ou coleções geradas em tempo de execução.
1. Por Que Devemos Escrever Testes Parametrizados?
Suponha que precisemos especificar o comportamento esperado do método max(a: Int, b: Int) da biblioteca padrão (kotlin.math.max / java.lang.Math.max), cuja documentação afirma:
Retorna o maior entre dois valores
Int. Se os argumentos tiverem o mesmo valor, o resultado é esse mesmo valor.
Como o método max() não possui efeitos colaterais, podemos começar escrevendo um bloco should simples contendo duas asserções seguidas:
|
|
Embora esse teste pareça inofensivo à primeira vista, colocar múltiplas linhas repetidas dentro do mesmo teste apresenta quatro desvantagens sérias à medida que os cenários crescem:
- Dificuldade para carregar dados externos: Não é fácil alimentar esse formato a partir de uma fonte externa (como um arquivo JSON/CSV ou gerador dinâmico).
- Duplicação de código: Para adicionar novos casos de teste, precisamos copiar e colar novas linhas de asserção. Se a lógica de chamada envolver mais de uma linha, a duplicação torna o teste difícil de manter.
- Falha opaca e interrupção prematura: Se a primeira asserção (
max(1, 0) shouldBe 1) falhar, a segunda linha nem sequer será executada! Além disso, o nome do teste no relatório não mostra quais valores de entrada causaram a falha. - Falta de isolamento entre casos: Executar várias asserções seguidas dentro do mesmo bloco
shouldnão passa pelo ciclo de isolamento (beforeEach/afterEach) entre uma chamada e outra.
Felizmente, existe uma abordagem muito melhor: se quisermos executar a mesma lógica de verificação múltiplas vezes com diferentes entradas e resultados esperados, devemos usar o suporte a Data-Driven Testing (withData) do Kotest 6!
2. Reescrevendo a Expectativa com Variáveis de Dados
No Spock Framework, substituíamos os literais fixos por variáveis a, b e c alimentadas por um bloco where:.
No Kotest 6, o suporte a testes orientados a dados já vem incluído diretamente no kotest-framework-engine (através do pacote io.kotest.datatest.withData). Em vez de tabelas sem tipo verificadas apenas em tempo de execução, representamos uma linha da nossa tabela como uma data class totalmente tipada pelo compilador Kotlin:
|
|
E dentro do bloco withData, desestruturamos (a, b, c) para executar nossa asserção:
|
|
Aqui, a e b contêm os parâmetros passados ao método max(), e c contém o valor esperado como retorno.
Antes de fornecermos os dados de entrada com withData, vamos entender como o Kotest trata cada linha de dados como uma iteração isolada.
3. Introdução às Iterações no withData
Quando passamos uma lista de casos para withData(...), o Kotest registra cada elemento como um caso de teste individual na árvore da JUnit Platform! Cada invocação é uma iteração independente que goza do mesmo isolamento de um teste normal:
- Os callbacks
beforeEacheafterEachsão executados antes e depois de cada linha de dados. - Se a primeira linha de dados falhar, todas as demais linhas continuam sendo executadas normalmente e todas as falhas são reportadas individualmente!
- O Kotest gera automaticamente o nome de exibição de cada iteração a partir do
toString()dadata class(ou de um lambda customizado de nomeação!).
Conceitualmente, nossas duas iterações equivalem a executar dois testes isolados:
- 1ª Iteração (
a = 1, b = 0, c = 1): verifica quemax(1, 0) shouldBe 1. - 2ª Iteração (
a = 2, b = 3, c = 3): verifica quemax(2, 3) shouldBe 3.
Vamos ver como fornecer esses dados na prática!
4. Fornecendo os Dados de Entrada
Podemos fornecer dados de entrada para withData de duas maneiras: usando conjuntos fixos em formato tabular (equivalente às Data Tables do Spock) ou usando provedores/sequências dinâmicas (equivalente aos Data Pipes do Spock).
4.1. Fornecendo Dados Fixos em Formato Tabular (withData)
Para criar uma tabela de dados fixa e legível no Kotest 6, declaramos nossa data class e passamos as linhas diretamente como argumentos vararg de withData(...):
|
|
Quer personalizar o nome de exibição de cada linha no relatório da IDE (por exemplo, "max(1, 0) == 1")? Basta passar uma função nameFn como primeiro argumento do withData:
|
|
Ao executar MathSpec, o relatório exibirá cada linha claramente:
|
|
[!TIP] Você também pode passar um
Map<String, MaxRow>parawithData(mapOf("primeiro maior" to MaxRow(1, 0, 1), ...))onde a chave do mapa se torna automaticamente o nome do caso de teste!
4.2. Fornecendo Dados Dinâmicos (Equivalente aos Data Pipes)
Tabelas fixas são excelentes quando conhecemos todos os valores estaticamente no código. Porém, e quando os dados vêm de coleções dinâmicas, sequências, arquivos externos ou combinações programáticas (como fazíamos com os data pipes a << [1, 2] no Spock)?
A função withData do Kotest aceita qualquer Sequence<T> ou Iterable<T>! Podemos inclusive combinar listas paralelas de entradas (aValues, bValues, cValues) ou carregar linhas de um recurso externo usando as funções funcionais da biblioteca padrão do Kotlin:
|
|
Além disso, como withData pode ser aninhado dentro de outro withData, você também pode testar o produto cartesiano de múltiplos conjuntos dinâmicos com facilidade!
Resumo
Nesta aula que encerra o Tópico 01 do Capítulo 03, aprendemos quatro pontos essenciais:
- Devemos escrever testes orientados a dados sempre que precisarmos executar a mesma lógica de verificação múltiplas vezes para diferentes entradas e saídas esperadas.
- Cada linha passada para
withDataé registrada como um teste independente na JUnit Platform, recebendo isolamento completo de ciclo de vida (beforeEach/afterEach) e relatórios individuais de falha. - Usamos
withDatacom argumentos vararg de umadata classquando temos um conjunto fixo e tabular de casos de teste. - Usamos
withDatarecebendo umIterable<T>ouSequence<T>quando nossos dados de teste são carregados ou gerados dinamicamente.