Criando Spies com spyk no MockK

Ao concluirmos esta aula, nós:

  • Compreenderemos qual é a diferença fundamental entre um Mock e um Spy criado com uma biblioteca de mocking.
  • Saberemos como criar Spies utilizando spyk() no MockK (e Mockito.spy() no Mockito).
  • Seremos capazes de substituir seletivamente o comportamento de métodos (partial stubbing) e verificar interações ocorridas em um Spy.

[!NOTE] Esta aula assume que:


Um Spy Não É um Mock!

Antes de criarmos spies com o MockK (ou com o Mockito), precisamos compreender que os spies criados por bibliotecas de mocking modernas são um pouco diferentes tanto dos spies manuais tradicionais que discutimos anteriormente quanto dos objetos mock puros. A melhor maneira de demonstrar essa diferença é comparar diretamente como um Mock e um Spy funcionam por baixo dos panos:

  1. Primeiro, quando criamos um Mock (mockk<T>() / Mockito.mock()): A biblioteca de mocking não executa o código real da classe. Ela cria uma casca (proxy) com a mesma API do objeto real, mas jamais invoca os métodos reais. Em vez disso, nosso objeto mock retorna apenas a resposta que configuramos com every { ... } returns ... (ou a resposta padrão caso seja relaxado).
flowchart LR SUT["Sistema Sob Teste (SUT)"] -->|"1. Invoca método"| Mock["Objeto Mock (Proxy)"] Mock -->|"2. Retorna resposta stubada/padrão (NÃO chama código real)"| SUT
  1. Segundo, quando criamos um Spy (spyk(realObject) / Mockito.spy(realObject)): A biblioteca cria um wrapper que possui a mesma API do objeto real e que delega todas as chamadas de método para o objeto real envolvido. Em outras palavras, quando invocamos um método de um spy, o spy grava as informações dessa invocação no histórico e, em seguida, executa o método verdadeiro do objeto real!
flowchart LR SUT["Sistema Sob Teste (SUT)"] -->|"1. Invoca método"| Spy["Objeto Spy (Wrapper)"] Spy -->|"2. Grava a chamada e delega"| Real["Objeto Real (TaskRepositoryImpl)"] Real -->|"3. Retorna resultado real"| Spy Spy -->|"4. Devolve resultado"| SUT

Quando falamos sobre spies manuais clássicos no Tópico 04, dissemos que um spy puro não substituía respostas de métodos. Porém, quando criamos um spy com o MockK (spyk) ou Mockito (spy), podemos fazer stubbing seletivo de métodos específicos do spy! Se configurarmos o retorno de um método específico em um spy, o spy não invocará o método real quando aquele método stubado for chamado pelo Sistema Sob Teste (enquanto todos os demais métodos continuarão executando o código real).

Essa é uma técnica poderosa (embora deva ser usada com parcimônia) quando precisamos lidar com código legado que não foi originalmente projetado para ser testado.

Vamos conhecer a classe real que envolveremos com nosso spy nesta aula.


Introdução ao Sistema Sob Teste (SUT)

Durante esta aula, criaremos um spy que envolve uma classe concreta que implementa a interface TaskRepository. O nome dessa classe é TaskRepositoryImpl, e cada método da interface TaskRepository é implementado seguindo estes dois requisitos:

  • O método registra no log o nome do método invocado e seus parâmetros.
  • O método retorna uma resposta vazia (0L, Optional.empty() ou null).

O código-fonte da classe TaskRepositoryImpl em Kotlin é o seguinte:

TaskRepositoryImpl.kt
Arquivo Real 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
29
30
package com.testwithspring.starter.mockk

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

class TaskRepositoryImpl : TaskRepository {

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

    override fun count(): Long {
        LOGGER.info("Invoked count() method")
        return 0L
    }

    override fun delete(task: Task) {
        LOGGER.info("Invoked delete() method with task: {}", task)
    }

    override fun findById(id: Long): Optional<Task> {
        LOGGER.info("Invoked findById() method with id: {}", id)
        return Optional.empty()
    }

    override fun save(task: Task): Task {
        LOGGER.info("Invoked save() method with task: {}", task)
        return task
    }
}

Vamos descobrir como podemos criar um spy que envolve um objeto TaskRepositoryImpl utilizando o MockK!


Criando Spies com o MockK (spyk)

O MockK fornece duas sobrecargas principais da função spyk() (equivalentes aos dois métodos Mockito.spy() do Mockito):

  • spyk<T>() (sem argumentos): Cria um spy instanciando automaticamente a classe T através do seu construtor padrão sem argumentos (ou criando uma instância de uma classe abstrata).
  • spyk(objectToSpy) (recebendo uma instância): Cria um spy que envolve o objeto objectToSpy existente passado como parâmetro.

Como sabemos, os dublês de teste criados com o MockK não são stateless. Por isso, devemos criar nosso spy antes que cada método de teste seja invocado, dentro de um método @BeforeEach.

Para criar um spy que envolve uma instância real de TaskRepositoryImpl, invocamos spyk(TaskRepositoryImpl()) (e não precisamos adicionar nenhum método em TestDoubles, pois o próprio nome spyk já deixa óbvio que estamos criando um spy).

Depois de fazermos isso, o código-fonte inicial da nossa classe de teste fica assim:

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

import io.mockk.spyk
import org.junit.jupiter.api.BeforeEach

class MockKSpyTest {

    private lateinit var repository: TaskRepository

    @BeforeEach
    fun createSpy() {
        repository = spyk(TaskRepositoryImpl())
    }
}

Depois de criarmos o nosso spy, podemos tanto substituir o comportamento de métodos específicos (stubbing) quanto verificar as interações ocorridas entre o Sistema Sob Teste e o nosso spy. Vamos começar descobrindo como fazer o stubbing de um método de um spy!


Configurando o Comportamento de um Método em um Spy

Aqui novamente vemos uma grande vantagem de design do MockK em relação ao Mockito clássico:

No Mockito clássico, quando você queria fazer stubbing de um método em um spy, você não podia usar a sintaxe normal given(repository.findById(1L)).willReturn(Optional.of(expected)) nem when(repository.findById(1L)).thenReturn(...)! Se você usasse a sintaxe normal no Mockito, o método real TaskRepositoryImpl.findById(1L) seria executado de verdade durante a linha de configuração antes de o Mockito interceptá-lo (imprimindo o log real ou até abrindo conexões reais!). Por isso, no Mockito era obrigatório usar a sintaxe invertida:

  • BDDMockito: willReturn(Optional.of(expected)).given(repository).findById(1L);
  • Mockito: doReturn(Optional.of(expected)).when(repository).findById(1L);

No MockK, o bloco every { ... } returns ... intercepta a chamada antes que o método real do spy seja invocado! Portanto, usamos exatamente a mesma sintaxe padrão every { repository.findById(1L) } returns Optional.of(expected)!

Veja como fica o método de teste que faz o stubbing do método findById(1L) em nosso spy:

MockKSpyStubTest.kt
Arquivo Real 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
29
30
31
package com.testwithspring.starter.mockk

import com.testwithspring.starter.mockk.TestDoubles.dummy
import io.mockk.every
import io.mockk.spyk
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import java.util.Optional

@DisplayName("06 - Configurando Comportamento Parcial em Spies com MockK")
class MockKSpyStubTest {

    private lateinit var repository: TaskRepository

    @BeforeEach
    fun createSpy() {
        repository = spyk(TaskRepositoryImpl())
    }

    @Test
    fun `deve retornar objeto configurado sem executar o metodo real do spy`() {
        val expected: Task = dummy()
        every { repository.findById(1L) } returns Optional.of(expected)

        val actual = repository.findById(1L)

        assertThat(actual).containsSame(expected)
    }
}

Quando executamos esse método de teste, notamos que o método real findById() da classe TaskRepositoryImpl não foi executado (nenhuma mensagem "Invoked findById() method with id: 1" aparece no log), e o spy retornou exatamente o Optional.of(expected) configurado! Enquanto isso, se chamarmos repository.count() (que não foi stubado), o spy executará o método real da classe TaskRepositoryImpl e imprimirá "Invoked count() method".

Vamos avançar e ver como verificar interações ocorridas com o nosso spy.


Verificando Interações em um Spy com o MockK

Podemos verificar as interações que aconteceram (ou não aconteceram) entre o Sistema Sob Teste e o nosso spy utilizando exatamente a mesma sintaxe verify { ... } que aprendemos na aula anterior (Criando Mocks e Verificando Interações com verify).

Por exemplo, se quisermos verificar que o método findById() da interface TaskRepository foi invocado uma vez no nosso spy e que 1L foi passado como parâmetro, escrevemos o seguinte teste unitário:

MockKSpyVerifyTest.kt
Arquivo Real 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
package com.testwithspring.starter.mockk

import io.mockk.spyk
import io.mockk.verify
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test

@DisplayName("06 - Verificando Interações em Spies com MockK")
class MockKSpyVerifyTest {

    private lateinit var repository: TaskRepository

    @BeforeEach
    fun createSpy() {
        repository = spyk(TaskRepositoryImpl())
    }

    @Test
    fun `deve verificar quando o metodo do spy foi chamado uma vez`() {
        repository.findById(1L)

        verify(exactly = 1) { repository.findById(1L) }
    }
}

Quando rodamos esse teste, o spy executa o método real TaskRepositoryImpl.findById(1L) (imprimindo a linha no log) e grava a chamada, permitindo que verify(exactly = 1) { repository.findById(1L) } confirme que a interação ocorreu com sucesso!


Executando Nossos Testes no Gradle

Ao executarmos a suíte de testes de Spies através do Gradle (./gradlew test --tests 'com.testwithspring.starter.mockk.MockKSpy*' -i), inspecionamos a evidência real de partial stubbing e verificação de interações:

Evidência Real de Execução: ./gradlew test --tests 'com.testwithspring.starter.mockk.MockKSpy*' -i
Terminal Log
Initialized native services in: /home/wc/.gradle/native
Initialized jansi services in: /home/wc/.gradle/native
The client will now receive all logging from the daemon (pid: 16102, address: [fe80::1%1:54330]).
Gradle Daemon started in 1 s 042 ms
Starting Build
Settings evaluated using settings file '/home/wc/test-with-spring/code/01-starter-junit6/settings.gradle.kts'.
Projects loaded. Root project using build file '/home/wc/test-with-spring/code/01-starter-junit6/build.gradle.kts'.
Included projects: [root project 'starter-junit6']

> Configure project :
Evaluating root project 'starter-junit6' using build file '/home/wc/test-with-spring/code/01-starter-junit6/build.gradle.kts'.
Using Kotlin Gradle Plugin 2.4.21
Using Java Toolchain: Temurin 21.0.6 (/home/wc/.sdkman/candidates/java/21.0.6-tem)
Spring Boot 4.1.2 plugin applied.
All projects evaluated.
Selected primary task 'test' from project :
Tasks to be executed: [task ':compileKotlin', task ':compileJava', task ':processResources', task ':classes', task ':compileTestKotlin', task ':compileTestJava', task ':processTestResources', task ':testClasses', task ':test']
Tasks that were skipped: [task ':compileJava', task ':compileTestJava']

> Task :compileKotlin UP-TO-DATE
> Task :processResources NO-SOURCE
> Task :classes UP-TO-DATE
> Task :compileTestKotlin UP-TO-DATE
> Task :processTestResources NO-SOURCE
> Task :testClasses UP-TO-DATE
> Task :test
Starting process 'Gradle Test Executor 1'. Working directory: /home/wc/test-with-spring/code/01-starter-junit6 Command: /home/wc/.sdkman/candidates/java/21.0.6-tem/bin/java -ea -cp ...
Successfully started process 'Gradle Test Executor 1'

com.testwithspring.starter.mockk.MockKSpyStubTest > deve retornar objeto configurado sem executar o metodo real do spy PASSED
com.testwithspring.starter.mockk.MockKSpyVerifyTest > deve verificar quando o metodo do spy foi chamado uma vez PASSED

06 - Criando Spies com spyk no MockK > 2 tests completed, 0 failed, 0 skipped
Finished generating test XML results (0.008 secs) into: /home/wc/test-with-spring/code/01-starter-junit6/build/test-results/test
Generating HTML test report...
Finished generating test html results (0.015 secs) into: /home/wc/test-with-spring/code/01-starter-junit6/build/reports/tests/test

BUILD SUCCESSFUL in 1s
5 actionable tasks: 1 executed, 4 up-to-date

Resumo

Nesta aula, aprendemos quatro pontos essenciais:

  • Um Spy criado com o MockK (spyk) ou Mockito (spy) envolve um objeto real e invoca seus métodos verdadeiros, a menos que tenhamos feito o stubbing explícito do método chamado.
  • Criamos spies no MockK utilizando a função spyk(realObject) (ou Mockito.spy(realObject) no Mockito).
  • No Mockito clássico, para fazer stubbing de um método de um spy sem disparar o método real, é obrigatório usar a sintaxe invertida (willReturn(...).given(spy) / doReturn(...).when(spy)). No MockK, o bloco every { spy.method() } returns ... já evita a execução do método real automaticamente.
  • Utilizamos exatamente a mesma sintaxe (verify { ... }) para verificar interações ocorridas entre o Sistema Sob Teste e nosso dublê de teste, seja ele um Mock ou um Spy.