Estilos de Especificação do Kotest (DescribeSpec, BehaviorSpec) e Ciclo de Vida

Depois de concluirmos esta aula, nós:

  • Saberemos como criar classes de especificação no Kotest 6 e escolher o estilo de especificação mais adequado (DescribeSpec, BehaviorSpec, ShouldSpec).
  • Conheceremos a estrutura interna completa de uma classe de especificação.
  • Compreenderemos a diferença entre propriedades isoladas por teste e propriedades compartilhadas, e como configurar o IsolationMode do Kotest.
  • Saberemos como utilizar os métodos/callbacks de fixture (beforeSpec, beforeEach, afterEach, afterSpec) e sua ordem exata de invocação.

1. Criando Nossa Primeira Especificação no Kotest

A classe que contém nossos casos de teste é chamada de classe de especificação (specification class). No Spock, toda classe estendia uma única classe base (Specification). No Kotest 6, estendemos a classe base correspondente ao estilo de especificação que preferirmos para o nosso projeto, passando uma lambda para o construtor (ou sobrescrevendo o bloco init):

  1. Criamos uma nova classe Kotlin em src/test/kotlin.
  2. Estendemos um dos estilos do pacote io.kotest.core.spec.style.* — os três mais utilizados em aplicações Spring Boot são:
    • DescribeSpec: inspirado em RSpec/Jasmine/Mocha, organiza os testes em blocos describe("...") e it("...").
    • BehaviorSpec: inspirado em BDD clássico, organiza os testes em blocos Given("..."), When("...") e Then("...").
    • ShouldSpec: enxuto e direto, organiza os testes em blocos context("...") e should("...").
  3. Opcionalmente, configuramos a tag da especificação com @Tags("UnitTest").

Veja como fica uma classe de especificação simples recém-criada:

1
2
3
4
5
6
7
8
9
package com.testwithspring.master

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

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

})

[!NOTE] Duas observações importantes antes de prosseguirmos:

  • A anotação @Tags("UnitTest") só é necessária se você filtrar a execução por tags no seu build. Se você já separa seus testes usando diretórios dedicados do jvm-test-suite (src/test/kotlin, src/integrationTest/kotlin, src/endToEndTest/kotlin), ela é totalmente opcional.
  • Seguimos a convenção de nomenclatura de adicionar o sufixo Spec ao nome do sistema sob especificação (por exemplo, MessageService -> MessageServiceSpec).

Nossa especificação ainda está vazia. Antes de adicionarmos testes a ela, vamos conhecer as partes que compõem a estrutura de uma especificação do Kotest.


2. A Estrutura de uma Especificação do Kotest

Uma classe de especificação do Kotest pode conter os seguintes blocos:

  • Constantes (companion object): constantes estáticas (private const val) úteis para dar nomes semânticos a valores mágicos.
  • Propriedades / Campos (val / var): armazenam objetos que pertencem à fixture da especificação (como o sistema sob especificação e seus colaboradores/dublês).
  • Callbacks de Fixture (beforeSpec, beforeEach, afterEach, afterSpec): responsáveis por inicializar recursos ou configurar o sistema sob especificação antes da execução dos testes e limpá-los depois.
  • Blocos de Especificação (Feature Blocks): descrevem e verificam o comportamento esperado do sistema sob especificação (describe/it, Given/When/Then ou context/should).
  • Funções Auxiliares (Helper Functions): métodos privados declarados dentro da especificação para evitar duplicação de código nos testes.

O exemplo abaixo ilustra o esqueleto estrutural de uma especificação no 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.DescribeSpec

@Tags("UnitTest")
class ExampleSpec : DescribeSpec({
    // 1. Campos / Propriedades da Fixture
    // 2. Callbacks de Fixture (beforeSpec, beforeEach, afterEach, afterSpec)
    // 3. Blocos de Especificação (describe / it)
    // 4. Funções Auxiliares (Helper functions)
}) {
    companion object {
        // Constantes (const val)
    }
}

Vamos agora examinar em detalhes como funcionam as constantes, as propriedades isoladas e as propriedades compartilhadas!


3. Adicionando Constantes, Propriedades Isoladas e Propriedades Compartilhadas

No Spock Framework, os campos normais eram recriados para cada teste, enquanto campos anotados com @Shared eram compartilhados entre todos os testes.

No Kotest 6, você tem controle explícito e transparente sobre isso por meio do IsolationMode:

  • IsolationMode.InstancePerLeaf (ou IsolationMode.InstancePerTest): o Kotest cria uma nova instância fresca da classe de especificação para cada caso de teste folha executado! Isso significa que qualquer variável declarada no corpo da especificação (val messageService = MessageService()) não é compartilhada entre os testes — cada teste recebe seu próprio objeto isolado!
  • Objetos Compartilhados (companion object ou IsolationMode.SingleInstance): se criar um objeto for muito custoso (como um contêiner ou cliente pesado) e quisermos compartilhá-lo entre todos os testes da especificação, podemos declará-lo no companion object (ou inicializar dentro de um beforeSpec).

Vamos demonstrar na prática a diferença entre um objeto isolado por teste e um objeto compartilhado criando a classe FieldExampleSpec em três passos:

  1. Adicionamos uma constante MESSAGE = "Hello World!" no companion object.
  2. Configuramos isolationMode = IsolationMode.InstancePerLeaf e declaramos val messageService = MessageService(). Como a instância é isolada por teste folha, cada teste receberá sua própria instância de MessageService.
  3. Declaramos val sharedObject = Any() dentro do companion object para que ambos os testes recebam exatamente a mesma referência em memória.
 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
package com.testwithspring.master

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

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

    isolationMode = IsolationMode.InstancePerLeaf

    val messageService = MessageService()

    should("Get message one") {
        println("First feature method")
        println("unique object: $messageService")
        println("shared object: $sharedObject")

        messageService.getMessage() shouldBe MESSAGE
    }

    should("Get message two") {
        println("Second feature method")
        println("unique object: $messageService")
        println("shared object: $sharedObject")

        messageService.getMessage() shouldBe MESSAGE
    }
}) {
    companion object {
        private const val MESSAGE = "Hello World!"
        private val sharedObject = Any()
    }
}

Quando executamos FieldExampleSpec, vemos as seguintes linhas impressas em System.out:

1
2
3
4
5
6
First feature method
unique object: com.testwithspring.master.MessageService@6b419da
shared object: java.lang.Object@60dcc9fe
Second feature method
unique object: com.testwithspring.master.MessageService@309e345f
shared object: java.lang.Object@60dcc9fe

Observe os hashes de memória na saída acima:

  • O objeto armazenado em messageService possui hashes diferentes (@6b419da vs @309e345f), provando que não é compartilhado entre os testes!
  • O objeto armazenado em sharedObject possui o mesmo hash (@60dcc9fe), provando que é compartilhado entre todos os testes da especificação.

[!TIP] Você também pode definir IsolationMode.InstancePerLeaf globalmente para todo o seu projeto criando uma classe ProjectConfig : AbstractProjectConfig() com override val isolationMode = IsolationMode.InstancePerLeaf. Assim, você não precisa repetir essa linha em cada especificação!


4. Utilizando Callbacks de Fixture (beforeSpec, beforeEach, afterEach, afterSpec)

Os callbacks de fixture são usados para configurar e limpar o sistema sob especificação antes e depois dos testes. Todos eles são opcionais e suportam funções suspensas (suspend) do Kotlin nativamente!

Uma especificação do Kotest suporta quatro callbacks principais de ciclo de vida (equivalentes diretos aos métodos de fixture do Spock):

  1. beforeSpec { ... } (equivalente ao setupSpec()): é invocado uma única vez antes que o primeiro teste da especificação seja executado. Ideal para configurações pesadas compartilhadas.
  2. beforeEach { ... } (equivalente ao setup()): é invocado antes de cada caso de teste folha. Usamos esse bloco para configurar o sistema sob especificação ou resetar dublês de teste (clearMocks(...)).
  3. afterEach { ... } (equivalente ao cleanup()): é invocado após a conclusão de cada caso de teste folha. Usamos para limpar qualquer configuração feita no beforeEach.
  4. afterSpec { ... } (equivalente ao cleanupSpec()): é invocado uma única vez depois que todos os testes da especificação terminaram. Usamos para liberar recursos alocados no beforeSpec.

Vamos adicionar todos os quatro callbacks de fixture e dois casos de teste na nossa classe FixtureMethodExampleSpec:

 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
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 FixtureMethodExampleSpec : ShouldSpec({

    val messageService = MessageService()

    beforeSpec {
        println("Before the first feature method")
    }

    beforeEach {
        println("Before every feature method")
    }

    afterEach {
        println("After every feature method")
    }

    afterSpec {
        println("After the last feature method")
    }

    should("Get message one") {
        println("First feature method")
        messageService.getMessage() shouldBe MESSAGE
    }

    should("Get message two") {
        println("Second feature method")
        messageService.getMessage() shouldBe MESSAGE
    }
}) {
    companion object {
        private const val MESSAGE = "Hello World!"
    }
}

Quando executamos FixtureMethodExampleSpec, observamos a seguinte saída no console:

1
2
3
4
5
6
7
8
Before the first feature method
Before every feature method
First feature method
After every feature method
Before every feature method
Second feature method
After every feature method
After the last feature method

Essa saída comprova que os callbacks e os testes da nossa especificação são invocados exatamente nesta ordem:

  1. beforeSpec
  2. beforeEach
  3. Primeiro teste ("Get message one")
  4. afterEach
  5. beforeEach
  6. Segundo teste ("Get message two")
  7. afterEach
  8. afterSpec

Resumo

Nesta aula, aprendemos cinco conceitos fundamentais sobre as especificações do Kotest:

  • Toda classe de especificação do Kotest estende uma classe de estilo (DescribeSpec, BehaviorSpec, ShouldSpec, etc.).
  • Uma especificação pode conter constantes, propriedades de instância, callbacks de fixture, blocos de especificação e funções auxiliares.
  • Devemos preferir propriedades isoladas por teste (via IsolationMode.InstancePerLeaf ou reatribuição no beforeEach) para garantir que um teste nunca polua o estado do próximo.
  • Devemos usar objetos compartilhados (companion object / beforeSpec) apenas quando a criação do objeto for cara ou quando o compartilhamento for intencional.
  • Inicializamos e limpamos o sistema sob especificação de forma determinística usando beforeSpec, beforeEach, afterEach e afterSpec.