Usando Spies e Objetos Parciais em Kotest

Depois de concluirmos esta aula, nós:

  • Saberemos como obter as dependências necessárias para usar o MockK com Kotest no Gradle e no Maven.
  • Saberemos como criar spies (spyk) envolvendo objetos reais no MockK.
  • Compreenderemos como verificar as interações que ocorreram entre o sistema sob especificação e um spy.
  • Saberemos como sobrescrever (stub) métodos específicos de um spy sem executar o método real subjacente.

1. Conhecendo o Sistema Sob Especificação

Durante esta aula, criaremos um spy que envolve uma implementação real da interface MessageRepository chamada MessageRepositoryImpl e usaremos esse spy para especificar o comportamento da classe MessageService.

A classe MessageRepositoryImpl implementa dois métodos que registram logs via SLF4J (o que nos ajuda a comprovar visualmente quando o método real foi ou não executado):

  • O método findById(id: Long?) cria um novo objeto Message com o id recebido e o texto "Hello from Spy!", retornando Optional.of(found).
  • O método save(message: Message) define message.id = 3L e retorna o objeto message.

Veja o código-fonte de MessageRepositoryImpl em Kotlin:

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

import org.slf4j.LoggerFactory
import java.util.Optional

class MessageRepositoryImpl : MessageRepository {

    override fun findById(id: Long?): Optional<Message> {
        LOGGER.info("Finding message by id: {}", id)

        val found = Message(
            id = id,
            messageText = "Hello from Spy!"
        )
        return Optional.of(found)
    }

    override fun save(message: Message): Message {
        LOGGER.info("Saving a message with information: {}", message)

        message.id = 3L
        return message
    }

    companion object {
        private val LOGGER = LoggerFactory.getLogger(MessageRepositoryImpl::class.java)
    }
}

2. Obtendo as Dependências Necessárias

No Spock Framework, para criar spies (ou mocks de classes concretas), era necessário adicionar manualmente byte-buddy ou cglib-nodep ao classpath.

Com o MockK 1.14, o Byte Buddy já é gerenciado internamente pelo próprio artefato io.mockk:mockk-jvm, inclusive suportando classes final nativas do Kotlin sem exigir o plugin kotlin-allopen!

Se estivermos usando Maven, declaramos io.mockk:mockk-jvm no pom.xml:

1
2
3
4
5
6
<dependency>
    <groupId>io.mockk</groupId>
    <artifactId>mockk-jvm</artifactId>
    <version>1.14.2</version>
    <scope>test</scope>
</dependency>

Se estivermos usando Gradle 9.8.2 (Kotlin DSL), adicionamos io.mockk:mockk no bloco dependencies:

1
2
3
4
5
6
dependencies {
    testImplementation(platform("io.kotest:kotest-bom:6.2.6"))
    testImplementation("io.kotest:kotest-runner-junit5")
    testImplementation("io.kotest:kotest-assertions-core")
    testImplementation("io.mockk:mockk:1.14.2")
}

Vamos ver agora como criar um spy com MockK dentro da nossa especificação do Kotest.


3. Criando Spies com MockK (spyk)

Podemos criar um spy com o MockK utilizando a função spyk() de duas formas principais:

  1. spyk<MessageRepositoryImpl>(): sem passar argumentos, o MockK instancia um novo objeto chamando o construtor padrão sem argumentos da classe MessageRepositoryImpl e o envolve em um spy.
  2. spyk(MessageRepositoryImpl(...)): passando uma instância já criada como argumento. Essa opção é excelente quando a classe possui argumentos de construtor ou quando recebemos um objeto já instanciado.

Vamos configurar nossa especificação MessageServiceSpySpec criando um spy de MessageRepositoryImpl e injetando-o em MessageService:

 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.spyk

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

    isolationMode = IsolationMode.InstancePerLeaf

    val repository: MessageRepository = spyk(MessageRepositoryImpl())
    val service = MessageService(repository)
})

Quando criamos um spy com spyk(...), o MockK cria um wrapper com a mesma API do objeto real que delega todas as chamadas de método ao objeto real por padrão. Em outras palavras:

  1. O sistema sob especificação invoca um método do spy.
  2. O spy grava a chamada no histórico de interações do MockK.
  3. O spy invoca o método real de MessageRepositoryImpl e devolve o valor retornado pelo objeto real.

4. Verificando Interações em um Spy

Para verificar as interações ocorridas entre o sistema sob especificação e um spy, utilizamos exatamente o mesmo bloco verify(...) que aprendemos na aula anterior sobre mocks!

Vamos verificar que o método save() do spy MessageRepositoryImpl foi invocado uma vez quando criamos uma nova mensagem e que o objeto Message passado tinha o texto "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
27
28
29
30
31
32
33
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.kotest.matchers.shouldBe
import io.mockk.spyk
import io.mockk.verify

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

    isolationMode = IsolationMode.InstancePerLeaf

    val repository: MessageRepository = spyk(MessageRepositoryImpl())
    val service = MessageService(repository)

    Given("We want to create a new message using a spy") {
        val message = Message(messageText = "Hello World!")

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

            Then("Should invoke the real save method on the spy with the correct message text") {
                verify(exactly = 1) {
                    repository.save(match { it.messageText == "Hello World!" })
                }
                // Como o método real de MessageRepositoryImpl foi executado, o id foi alterado para 3L!
                returned.id shouldBe 3L
            }
        }
    }
})

Ao executar esse teste, veremos no console a linha de log Saving a message with information: Message(id=null, messageText=Hello World!) emitida por MessageRepositoryImpl, comprovando que o método real foi de fato executado!


5. Sobrescrevendo (Stubbing) o Comportamento de um Método do Spy

Se quisermos substituir o comportamento de um método específico de um spy (um “mock parcial”), precisamos ter em mente três pontos:

  1. Quando configuramos um método de um spy com every { repository.save(...) } answers { ... }, o método do objeto real NÃO é invocado quando o sistema sob especificação chama aquele método!
  2. Diferentemente do Mockito clássico (onde when(spy.save(...)) executava o método real durante a configuração e exigia doReturn(...).when(spy)), no MockK o bloco every { repository.save(...) } intercepta a chamada com segurança sem invocar o método real!
  3. Usar spies como mocks parciais é uma técnica poderosa mas controversa: ela pode ser útil ao lidar com código legado difícil de refatorar, mas em projetos novos (greenfield) devemos sempre preferir composição limpa com mocks/stubs puros.

Vamos escrever um teste onde sobrescrevemos o método save() do nosso spy MessageRepositoryImpl para atribuir id = 1L (em vez do 3L que o método real atribuiria) e verificamos que a interação ocorreu uma vez:

 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 stub the save method of our spy") {
        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 invoke the stubbed save method once") {
                verify(exactly = 1) { repository.save(message) }
            }

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

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

Quando executamos esse segundo cenário, nenhuma mensagem de log do MessageRepositoryImpl.save() é impressa no console e returned.id é 1L, provando que o método real foi suprimido pelo nosso stub!


Resumo

Nesta aula que encerra o Tópico 02 do Capítulo 03, aprendemos cinco pontos fundamentais:

  • O artefato io.mockk:mockk já traz todo o suporte de instrumentação necessário para criar spies de classes Kotlin (mesmo final).
  • Criamos um spy usando a função spyk(objetoReal) ou spyk<ClasseReal>().
  • Por padrão, qualquer chamada a um método de um spy é registrada pelo MockK e repassada para o método correspondente do objeto real.
  • Se configurarmos um método do spy com every { ... }, o método real não será executado quando o sistema sob especificação o invocar.
  • Verificamos as interações no spy usando verify(exactly = 1) { ... } dentro do bloco Then da nossa especificação do Kotest.