Estruturando Blocos Given/When/Then e Ciclo de Vida

Depois de concluirmos esta aula, nós:

  • Saberemos identificar os blocos construtivos de uma especificação de funcionalidade (feature specification) no Kotest 6.
  • Saberemos como estruturar cenários com efeitos colaterais usando os blocos Given, When, Then e And no BehaviorSpec.
  • Saberemos quando optar por blocos diretos (equivalentes ao bloco expect: do Spock) para especificar métodos puramente funcionais.
  • Saberemos como garantir a liberação de recursos alocados por um teste mesmo quando uma exceção é lançada (usando autoClose ou afterTest).

1. A Estrutura de uma Especificação de Funcionalidade

Um bloco de especificação descreve o comportamento esperado de uma funcionalidade (feature) implementada pelo sistema sob especificação. Tanto no Spock quanto no Kotest, nomeamos nossos cenários usando literais String com frases completas em linguagem natural — uma enorme vantagem de legibilidade para documentar regras de negócio.

Em uma especificação BDD completa, dividimos o cenário em blocos semânticos bem definidos:

  • Bloco de Preparação (Given / setup): é o primeiro bloco do cenário e contém a configuração do estado inicial da funcionalidade descrita.
  • Blocos de Estímulo e Resposta (When e Then): o bloco When descreve a ação/estímulo executado no sistema sob teste, e o bloco Then descreve a resposta ou mudança de estado esperada. Um mesmo Given pode conter múltiplos pares When / Then para testar diferentes estímulos sobre o mesmo estado inicial!
  • Bloco de Expectativa Direta (should / it — equivalente ao expect): descreve o estímulo e a resposta esperada em uma única expressão concisa, ideal para funções puras sem efeitos colaterais.
  • Limpeza de Recursos (autoClose / afterEach — equivalente ao cleanup): usado para liberar recursos (arquivos temporários, conexões, streams) abertos pelo teste, com garantia de execução mesmo se o teste lançar uma exceção.
  • Bloco de Dados (withData — equivalente ao where): usado para escrever testes orientados a dados (data-driven tests), que estudaremos em detalhes na Aula 06 deste tópico.

O diagrama conceitual abaixo resume essa estrutura no BehaviorSpec do Kotest:

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

import io.kotest.core.annotation.Tags
import io.kotest.core.spec.style.BehaviorSpec

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

    Given("A configuração / pré-condição da funcionalidade (setup)") {
        When("O estímulo é executado sobre o sistema") {
            Then("A resposta esperada é verificada") {
                // Asserções
            }
        }
    }
})

Vamos colocar esses blocos em prática especificando o comportamento do método get() da classe HashMap!


2. Escrevendo Especificações de Funcionalidade Passo a Passo

2.1. Usando o Bloco de Preparação (Given)

O bloco de preparação é o ponto de partida da nossa especificação e serve para configurar o objeto que será testado.

Em um estilo livre como ShouldSpec ou FunSpec, você pode simplesmente escrever o código de preparação implicitamente nas primeiras linhas do teste:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@Tags("UnitTest")
class HashMapImplicitSetupSpec : ShouldSpec({

    should("Get value from a map") {
        val map = HashMap<String, Int>()
        val key = "key"
        val value = 1
        map[key] = value
        // ...
    }
})

Contudo, quando queremos deixar o formato Given-When-Then explícito no código e nos relatórios de teste, utilizamos o BehaviorSpec e abrimos um bloco Given descrevendo a pré-condição com uma String:

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

import io.kotest.core.annotation.Tags
import io.kotest.core.spec.style.BehaviorSpec

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

    Given("Map contains one key-value pair") {
        val map = HashMap<String, Int>()
        val key = "key"
        val value = 1
        map[key] = value
    }
})

Depois de configurarmos a pré-condição no Given, precisamos especificar o comportamento esperado. Vamos fazer isso com os blocos When e Then.

2.2. Usando os Blocos When e Then

Especificamos o comportamento esperado combinando os blocos When (que executa o estímulo) e Then (que verifica o resultado esperado).

Vamos adicionar nosso primeiro par When / Then dentro do bloco Given para verificar que o método get() do HashMap retorna o valor associado quando a chave existe:

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

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

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

    Given("Map contains one key-value pair") {
        val map = HashMap<String, Int>()
        val key = "key"
        val value = 1
        map[key] = value

        When("A value is found with the given key") {
            val found = map[key]

            Then("Should return the found value") {
                found shouldBe value
            }
        }
    }
})

Como vimos antes, um mesmo contexto Given pode ter múltiplos blocos When e Then. Vamos adicionar um segundo bloco When / Then que verifica que o método get() do HashMap retorna null quando nenhum valor é encontrado para a chave informada (incorrectKey):

 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
package com.testwithspring.master

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

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

    Given("Map contains one key-value pair") {
        val map = HashMap<String, Int>()
        val key = "key"
        val value = 1
        map[key] = value

        When("A value is found with the given key") {
            val found = map[key]

            Then("Should return the found value") {
                found shouldBe value
            }
        }

        When("A value is not found with the given key") {
            val incorrectKey = "incorrectKey"
            val found = map[incorrectKey]

            Then("Should return null") {
                found shouldBe null
            }
        }
    }
})

[!TIP] No BehaviorSpec do Kotest 6, você também pode usar o bloco intermediário And("...") tanto após um Given quanto após um When ou Then caso queira detalhar múltiplas pré-condições ou verificações encadeadas!

Ao observar o código acima, notamos que criar uma variável intermediária val found = map[key] dentro de um bloco When apenas para ler um valor sem efeito colateral parece um pouco artificial. Vamos ver como simplificar isso!

2.3. Usando Blocos de Expectativa Direta (Equivalente ao expect: e and:)

Quando queremos descrever o estímulo e a resposta esperada em uma única expressão concisa (como fazíamos com os blocos expect: e and: no Spock), o estilo ShouldSpec (ou DescribeSpec) do Kotest permite escrever cada expectativa diretamente dentro de um bloco should("...") agrupado sob um context("..."):

 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
package com.testwithspring.master

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

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

    context("Given a map that contains one key-value pair") {
        val map = HashMap<String, Int>()
        val key = "key"
        val value = 1
        map[key] = value

        should("return the found value when a value is found with the given key") {
            map[key] shouldBe value
        }

        should("return null when a value is not found with the given key") {
            val incorrectKey = "incorrectKey"
            map[incorrectKey] shouldBe null
        }
    }
})

Fica claro que podemos especificar o comportamento esperado tanto com When/Then (BehaviorSpec) quanto com expectativas diretas (ShouldSpec / DescribeSpec). Como escolher a ferramenta certa para cada situação?

A diretriz clássica de BDD responde perfeitamente a essa dúvida:

Regra prática: Use blocos When-Then para descrever métodos que possuem efeitos colaterais (mutações de estado, salvamento em banco, envio de mensagens) e use expectativas diretas (should / expect) para descrever métodos puramente funcionais (cálculos e consultas sem mutação).


2.4. Limpando Recursos com Garantia de Execução (autoClose e try / finally)

Às vezes, nosso teste aloca recursos externos (como criar um arquivo no disco, abrir um InputStream ou iniciar um cliente HTTP) que precisam obrigatoriamente ser liberados ao final do teste, mesmo se uma asserção falhar ou uma exceção for lançada no meio do caminho.

No Spock, usávamos o rótulo cleanup:. No Kotest 6, temos duas maneiras idiomáticas de garantir essa limpeza:

  1. Para recursos que implementam AutoCloseable / Closeable: usamos a função utilitária autoClose(...) nativa do Kotest, que registra o recurso e o fecha automaticamente ao final do ciclo de vida!
  2. Para limpeza arbitrária (como deletar um File criado no teste): usamos afterEach / afterTest ou um bloco try { ... } finally { file.delete() } (ou ainda a função tempfile() nativa do io.kotest.engine.spec.tempfile que já agenda a remoção automática!).

Vamos ver o exemplo equivalente ao FileSpec que cria um arquivo /tmp/foo.txt, verifica que ele existe e é um arquivo regular, e garante sua remoção no bloco finally (mesmo que o teste lance uma exceção):

 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
package com.testwithspring.master

import io.kotest.core.annotation.Tags
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.matchers.booleans.shouldBeFalse
import io.kotest.matchers.booleans.shouldBeTrue
import java.io.File

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

    Given("A target file path in the temporary directory") {
        val file = File("/tmp/foo.txt")

        try {
            When("A new file is created") {
                file.createNewFile()

                Then("Should create a new file on disk") {
                    file.exists().shouldBeTrue()
                    file.isFile.shouldBeTrue()
                    file.isDirectory.shouldBeFalse()
                }
            }
        } finally {
            // Equivalente ao bloco cleanup: executado mesmo se ocorrer uma exceção!
            file.delete()
        }
    }
})

[!IMPORTANT] Assim como no bloco cleanup: do Spock, sempre escreva sua lógica de limpeza de forma defensiva (verificando se o recurso foi inicializado ou usando chamadas seguras ?.), pois uma exceção pode ter sido lançada antes mesmo de o recurso ter sido totalmente criado.


Resumo

Nesta aula, aprendemos seis pontos essenciais:

  • Uma especificação de funcionalidade é estruturada em blocos semânticos.
  • O bloco Given (ou context) configura o estado inicial da funcionalidade descrita.
  • Os blocos When e Then descrevem o estímulo executado e a resposta esperada, sendo ideais para métodos com efeitos colaterais.
  • Expectativas diretas (should / it) descrevem o estímulo e a resposta em uma única expressão, sendo ideais para métodos puramente funcionais.
  • Podemos garantir a limpeza de recursos alocados no teste usando autoClose(), tempfile() ou blocos try / finally e afterEach.
  • Podemos encadear ou subdividir etapas dentro de um cenário usando blocos And ou múltiplos blocos When / Then.