Criando Stubs em Kotest com Matchers de Argumentos

Depois de concluirmos esta aula, nós:

  • Saberemos como criar e isolar stubs usando MockK 1.14 dentro de especificações do Kotest 6.
  • Saberemos como configurar os parâmetros esperados de um método stubbado utilizando todas as restrições de argumentos (argument matchers) do MockK: igualdade exata, desigualdade (not), qualquer argumento incluindo nulo (anyNullable), qualquer lista de argumentos, qualquer argumento não nulo de um tipo específico (any<Long>()) e predicados customizados (match { ... }).

1. Conhecendo o Sistema Sob Especificação (SUS)

Durante esta aula, criaremos um stub para a interface MessageRepository e o utilizaremos para especificar o comportamento esperado da classe MessageService.

A interface MessageRepository declara um método chamado findById(), que recebe o id da mensagem como parâmetro e retorna um Optional<Message> contendo a mensagem encontrada (ou Optional.empty() caso nenhuma mensagem seja encontrada):

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

import java.util.Optional

interface MessageRepository {
    fun findById(id: Long?): Optional<Message>
}

A classe Message armazena os dados de uma única mensagem:

1
2
3
4
5
6
package com.testwithspring.master.message

data class Message(
    var id: Long? = null,
    var messageText: String? = null
)

A classe MessageService recebe MessageRepository via construtor e possui um método findById(id: Long?) que consulta o repositório e retorna a Message encontrada ou lança NotFoundException se o Optional estiver vazio:

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

class MessageService(
    private val repository: MessageRepository
) {

    fun findById(id: Long?): Message {
        return repository.findById(id).orElseThrow {
            NotFoundException("No message found with id: $id")
        }
    }
}

Vamos ver agora como criar e isolar stubs com MockK dentro das nossas especificações do Kotest.


2. Criando e Isolando Stubs no Kotest com MockK

No Spock Framework, criávamos um stub invocando Stub(MessageRepository) (que por padrão aceita chamadas inesperadas retornando valores vazios/padrão).

No ecossistema moderno Kotlin com Kotest 6 + MockK 1.14, temos duas maneiras de criar um stub:

  1. mockk<MessageRepository>() (Estrito por padrão): exige que você configure explicitamente (every { ... } returns ...) os métodos que serão chamados durante o teste. Se o sistema sob especificação chamar um método não configurado, o MockK falha imediatamente avisando qual chamada faltou configurar.
  2. mockk<MessageRepository>(relaxed = true) (Relaxado — comportamento idêntico ao Stub() do Spock): permite chamadas não configuradas e retorna automaticamente valores padrão (como 0 para números, "" para String, coleções vazias ou objetos dummy).

[!IMPORTANT] Isolamento de Stubs entre Casos de Teste no Kotest

Quando criamos stubs em uma especificação do Kotest, devemos garantir que a configuração (every { ... }) feita em um caso de teste nunca vaze para os demais casos de teste da mesma classe! Temos duas maneiras simples de garantir isso:

  1. Configurar isolationMode = IsolationMode.InstancePerLeaf na especificação (assim cada teste ganha uma instância novinha de repository e service).
  2. Ou chamar clearMocks(repository) dentro de um bloco beforeEach { ... }.

Vamos criar nossa classe de especificação MessageServiceSpec configurando o stub repository e o serviço service:

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

import com.testwithspring.master.UnitTest
import io.kotest.core.annotation.Tags
import io.kotest.core.spec.IsolationMode
import io.kotest.core.spec.style.BehaviorSpec
import io.mockk.mockk

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

    isolationMode = IsolationMode.InstancePerLeaf

    val repository = mockk<MessageRepository>(relaxed = true)
    val service = MessageService(repository)
})

Agora que configuramos nosso sistema sob especificação, vamos explorar todas as restrições de argumentos (argument constraints / matchers) para identificar o método stubbado e seus parâmetros esperados!


3. Usando Restrições de Argumentos (Argument Constraints)

No MockK, identificamos o método stubbado dentro de um bloco every { ... } usando a sintaxe normal de chamada de método do Kotlin e especificamos os parâmetros esperados usando valores literais ou argument matchers.

Vamos analisar todos os 7 tipos de restrições de argumentos equivalentes às restrições do Spock.

3.1. Argumento Igual a um Valor (eq ou Valor Literal)

Se quisermos garantir que o método findById() do MessageRepository seja invocado com um parâmetro exatamente igual a 1L, basta passar 1L (ou eq(1L)) diretamente na chamada dentro do every:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
    Given("A message is found with an id that is equal to 1") {
        val found = Message(id = 1L, messageText = "Hello World!")
        every { repository.findById(1L) } returns Optional.of(found)

        When("We find a message by using the id 1") {
            val returned = service.findById(1L)

            Then("Should return the found message") {
                returned shouldBe found
            }
        }
    }

[!NOTE] Na próxima aula deste tópico, estudaremos em detalhes todas as variações de resposta (returns, returnsMany, answers, throws). Aqui usamos returns Optional.of(found) para retornar um valor fixo (equivalente ao operador >> do Spock).

3.2. Argumento Diferente de um Valor (not / neq)

Se quisermos configurar o stub para responder quando o parâmetro passado não for igual a 2L (equivalente ao !2L do Spock), usamos o matcher not(2L) (ou neq(2L)) do MockK:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
    Given("A message is found with an id that is not equal to 2") {
        val found = Message(id = 1L, messageText = "Hello World!")
        every { repository.findById(not(2L)) } returns Optional.of(found)

        When("We find a message by using the id 1") {
            val returned = service.findById(1L)

            Then("Should return the found message") {
                returned shouldBe found
            }
        }
    }

3.3. Qualquer Argumento Único, Incluindo null (anyNullable())

No Spock, o caractere sublinhado _ aceitava qualquer argumento único, inclusive null.

No MockK, como o Kotlin possui null-safety no sistema de tipos, existem dois matchers curinga:

  • any(): aceita qualquer argumento não nulo do tipo esperado.
  • anyNullable() (ou allAny()): aceita qualquer argumento, incluindo null!

Vamos configurar nosso stub para retornar a mensagem encontrada mesmo quando service.findById(null) for invocado:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
    Given("A message is found with any single argument (includes null)") {
        val found = Message(id = 1L, messageText = "Hello World!")
        every { repository.findById(anyNullable()) } returns Optional.of(found)

        When("We find a message by using null id") {
            val returned = service.findById(null)

            Then("Should return the found message") {
                returned shouldBe found
            }
        }
    }

3.4. Qualquer Lista de Argumentos / Varargs (anyVararg() / allAny())

Se o método possuir múltiplos parâmetros ou um vararg e quisermos aceitar qualquer combinação de argumentos (equivalente ao *_ do Spock), podemos usar allAny() nos parâmetros ou anyVararg() para arrays variáveis:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
    Given("A message is found when any argument is given via allAny()") {
        val found = Message(id = 1L, messageText = "Hello World!")
        every { repository.findById(allAny()) } returns Optional.of(found)

        When("We find a message by using the id 1") {
            val returned = service.findById(1L)

            Then("Should return the found message") {
                returned shouldBe found
            }
        }
    }

3.5. Argumento Não Nulo (not(isNull()) ou any())

Se quisermos garantir explicitamente que o método findById() só retorne a mensagem quando o argumento passado não for null (equivalente ao !null do Spock), usamos not(isNull()) (ou simplesmente any() para tipos não nulos):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
    Given("A message is found with any non-null id") {
        val found = Message(id = 1L, messageText = "Hello World!")
        every { repository.findById(not(isNull())) } returns Optional.of(found)

        When("We find a message by using the id 1") {
            val returned = service.findById(1L)

            Then("Should return the found message") {
                returned shouldBe found
            }
        }
    }

3.6. Argumento Não Nulo de um Tipo Específico (any<Long>() / ofType(Long::class))

Para exigir que o parâmetro seja não nulo e pertença a um tipo específico X (equivalente ao _ as Long do Spock), utilizamos o parâmetro de tipo reificado do MockK any<Long>() ou ofType(Long::class):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
    Given("A message is found with any non-null id that is Long") {
        val found = Message(id = 1L, messageText = "Hello World!")
        every { repository.findById(ofType(Long::class)) } returns Optional.of(found)

        When("We find a message by using the id 1") {
            val returned = service.findById(1L)

            Then("Should return the found message") {
                returned shouldBe found
            }
        }
    }

3.7. Argumento que Satisfaz um Predicado Customizado (match { ... })

Por fim, se quisermos verificar uma condição customizada sobre o parâmetro recebido usando uma lambda booleana (equivalente à closure { it == 1L } do Spock), usamos o matcher match { ... } (ou matchNullable { ... }) do MockK:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
    Given("A message is found with id that satisfies the given predicate") {
        val found = Message(id = 1L, messageText = "Hello World!")
        every { repository.findById(match { it == 1L }) } returns Optional.of(found)

        When("We find a message by using the id 1") {
            val returned = service.findById(1L)

            Then("Should return the found message") {
                returned shouldBe found
            }
        }
    }

Resumo

Nesta aula, aprendemos cinco pontos fundamentais:

  • Criamos stubs em especificações do Kotest usando mockk<T>() (ou mockk<T>(relaxed = true) quando queremos que chamadas não configuradas retornem valores padrão).
  • Isolamos nossos stubs entre os casos de teste usando isolationMode = IsolationMode.InstancePerLeaf ou clearMocks(...) no beforeEach.
  • Configuramos restrições de argumentos dentro do bloco every { ... } usando literais, not(...), any(), anyNullable(), ofType(...) e allAny().
  • Podemos implementar restrições de argumentos arbitrárias e expressivas utilizando predicados com match { ... }.