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:

 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 null
    }
}

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:

 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.Test
import java.util.Optional

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()
        // No MockK, every { ... } intercepta o spy sem executar o método real!
        // (No BDDMockito seria obrigatório inverter: willReturn(Optional.of(expected)).given(repository).findById(1L))
        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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.testwithspring.starter.mockk

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

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!


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.