Verificando Interações em Especificações BDD

Depois de concluirmos esta aula, nós:

  • Saberemos como criar e isolar objetos mock com MockK 1.14 dentro de especificações do Kotest 6.
  • Compreenderemos como verificar as interações que ocorreram entre o sistema sob especificação e um mock (verify(exactly = ...), atLeast, atMost).
  • Saberemos como combinar o stubbing do retorno e a verificação da interação de um método no mesmo cenário BDD.

1. Criando Mocks com MockK em Especificações do Kotest

No Spock Framework, criávamos um objeto mock invocando o método Mock(MessageRepository). No ecossistema Kotlin com Kotest 6 + MockK 1.14, criamos mocks utilizando a função mockk<MessageRepository>() (ou mockk<MessageRepository>(relaxed = true) se quisermos que chamadas a métodos cujos retornos não foram configurados retornem valores padrão automaticamente, exatamente como o Mock() do Spock faz!).

Assim como vimos para os stubs, devemos garantir que cada caso de teste da especificação tenha seu próprio mock limpo para que interações registradas em um teste não interfiram no próximo. Conseguimos isso configurando isolationMode = IsolationMode.InstancePerLeaf (ou limpando o mock com clearMocks(repository) no beforeEach):

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

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 MessageServiceMockSpec : BehaviorSpec({

    isolationMode = IsolationMode.InstancePerLeaf

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

[!NOTE] Três observações rápidas antes de prosseguirmos:

  • O sistema sob especificação (MessageService, MessageRepository e Message) é o mesmo utilizado nas duas aulas anteriores.
  • Se a construção do sistema sob especificação exigir mais de uma linha de configuração, você pode inicializá-lo dentro de um callback beforeEach { ... }.
  • O MockK consegue criar mocks tanto de interfaces quanto de classes concretas (final por padrão em Kotlin), objects singletons e classes enumeradas sem exigir plugins extras no build!

Agora que configuramos nosso mock, vamos ver como verificar as interações que aconteceram entre o MessageService e o MessageRepository.


2. Verificando Interações com verify

Para verificar as interações ocorridas entre o sistema sob especificação e um mock usando o MockK dentro do bloco Then do Kotest, seguimos quatro passos:

  1. Especificamos a cardinalidade (número esperado de chamadas) nos parâmetros do bloco verify(...) — pode ser um número fixo (exactly = N) ou um intervalo (atLeast = X, atMost = Y).
  2. Identificamos o objeto mock cujo método deve (ou não deve) ter sido invocado.
  3. Identificamos o método invocado usando a sintaxe normal de chamada em Kotlin.
  4. Especificamos os parâmetros esperados usando valores literais ou argument matchers.

Vamos examinar sete exemplos que demonstram todas as variações de verificação de interações equivalentes ao Spock!

2.1. Verificando que um Método NÃO Foi Invocado (exactly = 0)

Vamos garantir que o método findById() do mock MessageRepository não foi invocado (equivalente a 0 * repository.findById(_) no Spock) quando criamos uma nova mensagem:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
    Given("We want to create a new message (checking findById was not called)") {
        val message = Message(messageText = "Hello World!")

        When("We create a new message") {
            service.create(message)

            Then("Should not find a message by id") {
                verify(exactly = 0) { repository.findById(anyNullable()) }
            }
        }
    }

[!TIP] Se você quiser garantir que o mock repository não sofreu nenhuma interação em nenhum de seus métodos, pode usar verify { repository wasNot Called }.

2.2. Verificando que um Método Foi Invocado Exatamente Uma Vez (exactly = 1)

Vamos verificar que o método save() do mock MessageRepository foi invocado exatamente uma vez passando o objeto message (equivalente a 1 * repository.save(message)):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
    Given("We want to create a new message (checking save was called once)") {
        val message = Message(messageText = "Hello World!")

        When("We create a new message") {
            service.create(message)

            Then("Should save the message once") {
                verify(exactly = 1) { repository.save(message) }
            }
        }
    }

2.3. Verificando que um Método Foi Invocado Entre X e Y Vezes (atLeast = X, atMost = Y)

Se quisermos garantir que um método do mock foi invocado entre X e Y vezes (equivalente a (0..2) * repository.save(message) no Spock), combinamos os parâmetros atLeast e atMost no verify:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
    Given("We want to create a new message (between 0 and 2 times)") {
        val message = Message(messageText = "Hello World!")

        When("We create a new message") {
            service.create(message)

            Then("Should save the message between 0 and 2 times") {
                verify(atLeast = 0, atMost = 2) { repository.save(message) }
            }
        }
    }

2.4. Verificando que um Método Foi Invocado Pelo Menos X Vezes (atLeast = X)

Para verificar que um método foi invocado pelo menos X vezes (equivalente a (1.._) * repository.save(message)), usamos verify(atLeast = 1) (que também é o comportamento padrão de verify { ... } quando nenhum parâmetro de cardinalidade é informado):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
    Given("We want to create a new message (at least once)") {
        val message = Message(messageText = "Hello World!")

        When("We create a new message") {
            service.create(message)

            Then("Should save the message at least one time") {
                verify(atLeast = 1) { repository.save(message) }
            }
        }
    }

2.5. Verificando que um Método Foi Invocado No Máximo X Vezes (atMost = X)

Para garantir que um método foi invocado no máximo X vezes (equivalente a (_..1) * repository.save(message)), utilizamos verify(atMost = 1):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
    Given("We want to create a new message (at most once)") {
        val message = Message(messageText = "Hello World!")

        When("We create a new message") {
            service.create(message)

            Then("Should save the message at most one time") {
                verify(atMost = 1) { repository.save(message) }
            }
        }
    }

2.6. Permitindo Qualquer Número de Invocações, Incluindo Zero (atLeast = 0)

Se quisermos declarar que um método pode ter sido invocado qualquer número de vezes, inclusive zero (equivalente a _ * repository.save(message), útil quando usamos confirmVerified(repository) no final do teste e queremos marcar uma chamada opcional como reconhecida), usamos verify(atLeast = 0):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
    Given("We want to create a new message (any number of times)") {
        val message = Message(messageText = "Hello World!")

        When("We create a new message") {
            service.create(message)

            Then("Should allow any number of invocations") {
                verify(atLeast = 0) { repository.save(message) }
            }
        }
    }

2.7. Usando Restrições de Argumentos na Verificação (match / withArg)

Assim como no stubbing, podemos usar todos os argument matchers do MockK dentro do bloco verify! Vamos garantir que o método save() foi invocado exatamente uma vez recebendo uma Message cujo atributo messageText seja igual a "Hello World!":

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
    Given("We want to create a new message with specific text") {
        val message = Message(messageText = "Hello World!")

        When("We create a new message") {
            service.create(message)

            Then("Should save a message that has the correct message text") {
                verify(exactly = 1) {
                    repository.save(match { it.messageText == "Hello World!" })
                }
            }
        }
    }

[!TIP] Se você preferir usar os matchers ricos do próprio Kotest (shouldBe) para inspecionar o argumento capturado pelo MockK, pode usar withArg { ... } dentro do verify:

1
2
3
4
5
verify(exactly = 1) {
    repository.save(withArg { saved ->
        saved.messageText shouldBe "Hello World!"
    })
}

Se saved.messageText divergir, o Kotest lançará um AssertionError detalhado mostrando o valor esperado versus o valor real!


3. Combinando Stubbing e Verificação de Interação

Muitas vezes, queremos ao mesmo tempo:

  1. Configurar a resposta do método quando ele for chamado pelo sistema sob especificação (stubbing).
  2. Verificar que a interação realmente ocorreu com os parâmetros esperados (mocking).

No Spock, por conta de sua transformação AST, éramos obrigados a juntar a verificação e o stubbing na mesma linha dentro do bloco then: (1 * repository.save(message) >> { ... }), o que frequentemente confundia a separação temporal entre preparação e verificação.

No Kotest + MockK, mantemos a separação semântica cristalina do BDD:

  • Configuramos o comportamento do stub no bloco Given (ou And de preparação) usando every { ... } answers { ... }.
  • Executamos a ação no bloco When.
  • Verificamos a interação com verify(exactly = 1) e o objeto retornado no bloco Then!

Veja como fica nosso cenário completo para o método create():

 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
    Given("We want to create a new message and return the saved instance") {
        val message = Message(messageText = "Hello World!")

        every { repository.save(message) } answers {
            val saved = firstArg<Message>()
            saved.id = 1L
            saved
        }

        When("We create a new message") {
            val returned = service.create(message)

            Then("Should save the message once") {
                verify(exactly = 1) { repository.save(message) }
            }

            And("Should return the saved message with correct id") {
                returned.id shouldBe 1L
            }

            And("Should return the saved message with correct message text") {
                returned.messageText shouldBe "Hello World!"
            }
        }
    }

Resumo

Nesta aula, aprendemos cinco pontos fundamentais:

  • Criamos mocks em especificações do Kotest usando mockk<T>() (ou mockk<T>(relaxed = true)).
  • Garantimos que cada teste receba seu próprio mock isolado usando IsolationMode.InstancePerLeaf.
  • Especificamos a cardinalidade esperada das interações no verify usando exactly, atLeast e atMost.
  • Podemos inspecionar os argumentos passados ao mock usando match { ... } ou combinando withArg { ... } com as asserções do Kotest.
  • Quando precisamos configurar o retorno e verificar a chamada do mesmo método, configuramos o every { ... } no bloco Given e verificamos o verify(exactly = 1) { ... } no bloco Then.