Escrevendo Asserções Expressivas com kotest-assertions-core

Depois de concluirmos esta aula, nós:

  • Saberemos como adicionar asserções expressivas aos blocos das nossas especificações usando kotest-assertions-core.
  • Saberemos como escrever asserções para as exceções lançadas pelo sistema sob especificação (shouldThrow, shouldNotThrow).
  • Compreenderemos como utilizar matchers especializados de coleções e objetos (shouldHaveSize, shouldContain, shouldContainExactly) e agrupar múltiplas asserções com assertSoftly.

1. Introdução às Asserções no Kotest

No Spock Framework, escrevíamos condições booleanas implícitas dentro dos blocos then: e expect:. No Kotest 6, utilizamos a biblioteca kotest-assertions-core, que aproveita as infix functions e extension functions do Kotlin para criar asserções fluentes que se leem como frases em inglês natural (actual shouldBe expected).

Para escrever especificações fáceis de ler e dar manutenção, seguimos duas regras de ouro:

  1. Coloque as asserções mais importantes no início do bloco Then (ou should): se o teste falhar logo na pré-condição principal (por exemplo, o tamanho da lista retornada ou o status da resposta), você diagnostica o problema imediatamente.
  2. Separe grupos lógicos de asserções em blocos And descritivos: se suas asserções verificam aspectos distintos do resultado, divida o bloco Then usando And("...") com descrições claras.

Vamos ver como escrever asserções básicas nos blocos Then e should.


2. Escrevendo Asserções nos Blocos Then e should

Vamos analisar dois exemplos práticos que demonstram como adicionar asserções às nossas especificações na classe SimpleConditionSpec:

Primeiro, vamos verificar que a criação de uma nova lista resulta em uma lista vazia com tamanho zero dentro de um bloco When / Then:

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

import io.kotest.core.annotation.Tags
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.shouldBe

@Tags("UnitTest")
class SimpleConditionSpec : BehaviorSpec({

    Given("A scenario where we create a list") {
        When("We create a new empty list") {
            val list = emptyList<String>()

            Then("Should create an empty list") {
                list.shouldBeEmpty()
                list.size shouldBe 0
            }
        }
    }
})

[!NOTE] Na prática, não precisamos de ambas as linhas acima (list.shouldBeEmpty() já verifica se a lista está vazia e produz uma mensagem de erro riquíssima caso contenha elementos). Incluímos ambas aqui apenas para demonstrar que podemos combinar matchers de extensão específicos (shouldBeEmpty()) com o matcher de igualdade geral (shouldBe).

Segundo, vamos verificar que o método Math.max() retorna o maior entre dois números inteiros usando um bloco de expectativa direta (should):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package com.testwithspring.master

import io.kotest.core.annotation.Tags
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe
import kotlin.math.max

@Tags("UnitTest")
class SimpleDirectConditionSpec : ShouldSpec({

    should("return the bigger number") {
        max(1, 2) shouldBe 2
    }
})

A seguir, vamos descobrir como escrever asserções para exceções lançadas pelo sistema sob especificação.


3. Escrevendo Asserções para Exceções (shouldThrow e shouldNotThrow)

Quando queremos verificar se uma ação lança (ou não lança) uma exceção no Kotest 6, utilizamos as funções reificadas shouldThrow<T> { ... } e shouldNotThrow<T> { ... } (ou shouldNotThrowAny { ... }). Vamos examinar três casos de uso fundamentais!

3.1. Verificando que uma Exceção Específica É Lançada

Se quisermos verificar que tentar remover um elemento de uma pilha (Stack) vazia lança EmptyStackException, envolvemos a chamada stack.pop() com shouldThrow<EmptyStackException>:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.testwithspring.master

import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.annotation.Tags
import io.kotest.core.spec.style.BehaviorSpec
import java.util.EmptyStackException
import java.util.Stack

@Tags("UnitTest")
class ExceptionConditionSpec : BehaviorSpec({

    Given("An empty stack") {
        val stack = Stack<String>()

        When("We try to remove an element from an empty stack") {
            Then("Should throw EmptyStackException") {
                shouldThrow<EmptyStackException> {
                    stack.pop()
                }
            }
        }
    }
})

3.2. Verificando que uma Exceção NÃO É Lançada

Se quisermos garantir explicitamente que remover um elemento de uma pilha que contém um item não lança EmptyStackException, utilizamos shouldNotThrow<EmptyStackException>:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
    Given("The stack has one item") {
        val stack = Stack<String>()
        stack.push("Hello world!")

        When("We remove an element from the stack") {
            Then("Should not throw EmptyStackException") {
                shouldNotThrow<EmptyStackException> {
                    stack.pop()
                }
            }
        }
    }

3.3. Inspecionando a Exceção Lançada

Muitas vezes, não basta saber que uma RuntimeException foi lançada: precisamos inspecionar sua mensagem (message), código de erro customizado ou causa raiz (cause).

No Kotest, a função shouldThrow<T> { ... } retorna a instância tipada da exceção capturada! Assim, podemos armazená-la em uma variável val ex e fazer quantas asserções adicionais quisermos sobre ela:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
    Given("An action that fails with a message") {
        When("A RuntimeException is thrown with a message") {
            val ex = shouldThrow<RuntimeException> {
                throw RuntimeException("Hello World!")
            }

            Then("Should throw RuntimeException with the correct message") {
                ex.message shouldBe "Hello World!"
            }
        }
    }

4. Usando Matchers Especializados de Coleções e assertSoftly

No Spock Framework, recorríamos a matchers externos do Hamcrest (hasSize(1), contains('Hello World!')) quando queríamos asserções expressivas para coleções.

No Kotest 6, o próprio kotest-assertions-core já inclui centenas de matchers nativos em Kotlin para coleções, strings, mapas, datas, números e JSON, usando a sintaxe infixa:

[objeto ou coleção] [matcher] [valor esperado]

Vamos verificar que nosso método adiciona um item em uma lista em dois passos claros separados por And:

  1. Verificar que a lista possui exatamente 1 item (list shouldHaveSize 1).
  2. Garantir que a lista contém o item esperado (list shouldContain "Hello World!" ou list.shouldContainExactly("Hello World!")).
 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
package com.testwithspring.master

import io.kotest.core.annotation.Tags
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.collections.shouldHaveSize

@Tags("UnitTest")
class CollectionMatcherSpec : BehaviorSpec({

    Given("We create a new mutable list") {
        val list = mutableListOf<String>()

        When("A new item is added to the list") {
            list.add("Hello World!")

            Then("The list should have one item") {
                list shouldHaveSize 1
            }

            And("The list should contain the correct item") {
                list.shouldContainExactly("Hello World!")
            }
        }
    }
})

[!TIP] Dica Pro: Agrupando asserções com assertSoftly

Se você estiver validando várias propriedades de um mesmo DTO ou entidade dentro de um bloco Then, envolva as asserções com assertSoftly(returned) { ... }. Dessa forma, se a primeira propriedade falhar, o Kotest continuará avaliando todas as demais e exibirá um relatório consolidado com todas as divergências de uma só vez!


Resumo

Nesta aula, aprendemos cinco pontos importantes:

  • Especificamos o estado esperado utilizando os matchers fluentes do kotest-assertions-core (shouldBe, shouldBeEmpty, etc.) dentro dos blocos Then ou should.
  • Devemos posicionar as asserções mais importantes no início do bloco Then.
  • Quando nossas asserções pertencem a grupos lógicos distintos, dividimos a verificação usando blocos And("...") com boas descrições.
  • Capturamos e validamos exceções de forma tipada usando val ex = shouldThrow<MinhaException> { ... }.
  • O Kotest oferece matchers nativos riquíssimos para coleções (shouldHaveSize, shouldContainExactly) sem depender do Hamcrest.