Introdução a Spies

Ao concluirmos esta aula, nós:

  • Saberemos o que é um Spy.
  • Compreenderemos como um Spy funciona por dentro.
  • Compreenderemos quando devemos utilizar Spies em nossos testes.

[!NOTE] Esta aula assume que:


O Que É um Spy?

Um Spy (espião) é um objeto utilizado para substituir uma dependência externa do Sistema Sob Teste (SUT). Na definição clássica de padrões xUnit, todo spy cumpre os seguintes requisitos:

  • Um spy deve fornecer a mesma API que a dependência externa substituída: Se a dependência externa for uma classe, nosso spy deve estendê-la e sobrescrever os métodos que nos interessam. Se a dependência substituída implementar uma interface, nosso spy deve implementar essa mesma interface.
  • Um spy puro não fornece maneira de configurar a resposta retornada quando seu método é invocado.
  • Um spy não fornece maneira de especificar previamente os valores ou objetos esperados que devem ser passados como parâmetros quando um método do spy é invocado.
  • Um spy não possui um método verify() interno que valida expectativas pré-configuradas: Em vez disso, ele fornece métodos getter (propriedades de leitura) que são usados pelo teste para consultar as informações gravadas pelo spy durante a execução e validá-las com asserções normais.
  • Um spy grava o histórico de chamadas do método-alvo e os parâmetros passados nessas chamadas (podendo gravar a contagem de chamadas, a lista de argumentos recebidos, ou ambos).

Agora demonstraremos essas regras na prática criando um spy simples passo a passo. Vamos começar conhecendo a interface de dependência do nosso Sistema Sob Teste.


Introdução ao Sistema Sob Teste (SUT)

O nosso Sistema Sob Teste possui uma única dependência chamada TaskAuditLog. Essa interface declara um único método chamado logTask(), que recebe um objeto Task como parâmetro e não retorna nada (Unit).

O código-fonte da interface TaskAuditLog em Kotlin é o seguinte:

1
2
3
4
5
6
package com.testwithspring.starter.testdoubles

interface TaskAuditLog {

    fun logTask(task: Task)
}

Vamos descobrir como podemos criar um spy simples para a interface TaskAuditLog.


Criando um Spy Simples

Podemos criar um spy capaz de substituir objetos TaskAuditLog reais seguindo estes quatro passos:

Passo 1: Criar a classe TaskAuditLogSpy

Primeiro, criamos uma classe pública e final (padrão em Kotlin) que implementa a interface TaskAuditLog.

O código-fonte inicial da nossa classe spy fica assim:

1
2
3
class TaskAuditLogSpy : TaskAuditLog {

}

[!TIP] Nomenclatura de Classes Spy Ao nomear classes spy manuais, acrescentamos o sufixo Spy ao nome do tipo do objeto “real”. Como nossa classe substitui objetos TaskAuditLog, seu nome deve ser TaskAuditLogSpy.

Passo 2: Criar o mecanismo que grava o histórico de invocações

Segundo, criamos os campos que registram o histórico de chamadas do método logTask():

  1. Adicionamos um campo invocationCount: Int = 0 à classe spy. Esse campo registra quantas vezes o método logTask() foi invocado pelo Sistema Sob Teste.
  2. Adicionamos uma lista auditedTasks: MutableList<Task> = mutableListOf() à classe spy. Essa lista armazena todos os objetos Task que foram passados como parâmetro para o método logTask().

Após implementarmos essa estrutura, o código-fonte da classe TaskAuditLogSpy fica assim:

1
2
3
4
5
class TaskAuditLogSpy : TaskAuditLog {

    private var invocationCount: Int = 0
    private val auditedTasks: MutableList<Task> = ArrayList()
}

Passo 3: Implementar o método logTask()

Terceiro, implementamos o método logTask() da interface TaskAuditLog seguindo estes dois passos:

  1. Incrementamos o valor do campo invocationCount em 1.
  2. Adicionamos o objeto Task recebido à lista auditedTasks.

Após implementarmos o método logTask(), o código-fonte da classe TaskAuditLogSpy fica assim:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class TaskAuditLogSpy : TaskAuditLog {

    private var invocationCount: Int = 0
    private val auditedTasks: MutableList<Task> = ArrayList()

    override fun logTask(task: Task) {
        invocationCount++
        auditedTasks.add(task)
    }
}

Passo 4: Expor os métodos de leitura (getters) das informações gravadas

Quarto, implementamos os métodos getter utilizados pelo teste para acessar as informações registradas pelo nosso spy:

  1. O método getInvocationCount() retorna a quantidade de vezes que o método logTask() foi invocado.
  2. O método getAuditedTasks() retorna a lista de objetos Task que foram passados como parâmetro para o método logTask().

Após implementarmos esses métodos de acesso, o código-fonte completo da classe TaskAuditLogSpy fica assim:

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

class TaskAuditLogSpy : TaskAuditLog {

    private var invocationCount: Int = 0
    private val auditedTasks: MutableList<Task> = ArrayList()

    override fun logTask(task: Task) {
        invocationCount++
        auditedTasks.add(task)
    }

    fun getInvocationCount(): Int = invocationCount

    fun getAuditedTasks(): List<Task> = auditedTasks
}

Criamos nosso spy simples! A seguir, escreveremos um método de teste que demonstra como o nosso spy funciona.


Utilizando o Nosso Spy

Se quisermos usar um spy em nossos testes, devemos lembrar que spies não são stateless (eles acumulam o histórico de chamadas). Portanto, devemos criar uma nova instância do spy antes que cada método de teste seja invocado, utilizando um método @BeforeEach.

Depois de adicionarmos esse método de setup à nossa classe de teste, o código-fonte fica assim:

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

import org.junit.jupiter.api.BeforeEach

class TaskRepositorySpyTest {

    companion object {
        private const val TITLE = "example title"
    }

    private lateinit var auditLog: TaskAuditLogSpy

    @BeforeEach
    fun createSpy() {
        auditLog = TaskAuditLogSpy()
    }
}

Agora podemos escrever nosso método de teste seguindo estes quatro passos:

  1. Criamos um novo objeto Task.
  2. Invocamos o método logTask() da interface TaskAuditLog.
  3. Garantimos com uma asserção do AssertJ que o método logTask() foi invocado exatamente uma vez (getInvocationCount()).
  4. Garantimos com uma asserção do AssertJ que o objeto Task criado foi passado como parâmetro (getAuditedTasks()).

Após implementarmos nosso método de teste, o código-fonte completo da classe de teste fica assim:

 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
34
package com.testwithspring.starter.testdoubles

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test

class TaskRepositorySpyTest {

    companion object {
        private const val TITLE = "example title"
    }

    private lateinit var auditLog: TaskAuditLogSpy

    @BeforeEach
    fun createSpy() {
        auditLog = TaskAuditLogSpy()
    }

    @Test
    fun `logTask deve registrar a chamada e a tarefa auditada`() {
        val task = TaskBuilder()
            .withTitle(TITLE)
            .build()

        auditLog.logTask(task)

        val actualInvocationCount = auditLog.getInvocationCount()
        assertThat(actualInvocationCount).isEqualByComparingTo(1)

        val auditedTasks = auditLog.getAuditedTasks()
        assertThat(auditedTasks).containsExactly(task)
    }
}

Este exemplo demonstrou que um Spy é um dublê de teste que:

  • Monitora as invocações do método-alvo.
  • Pode gravar a contagem de chamadas do método-alvo e salvar os objetos passados como parâmetros para inspeção posterior.
  • Fornece métodos de leitura (getters) para acessar as informações gravadas.
  • Nos permite usar asserções padrão (AssertJ) sobre os dados gravados, em vez de configurar expectativas prévias como fazíamos no Mock manual.

A seguir, aprenderemos a identificar as situações em que devemos utilizar spies.


Quando Devemos Usar Spies?

Se estivermos desenvolvendo um projeto novo do zero (greenfield development), na maioria das vezes usaremos Stubs e Mocks. Dito isso, existem três situações específicas em que o padrão Spy é muito útil:

  1. Primeiro, quando precisamos verificar as interações entre o Sistema Sob Teste e nosso dublê de teste, mas não sabemos de antemão exatamente quantas vezes o método-alvo será invocado ou quais valores exatos serão passados antes de inspecioná-los em conjunto.
  2. Segundo, quando é difícil reportar uma expectativa falha de um mock de volta para o executor de testes (test runner) — por exemplo, se o Sistema Sob Teste estiver executando em outra thread, corrotina assíncrona ou dentro de um container separado, onde inspecionar o estado gravado pelo spy ao final é muito mais seguro.
  3. Terceiro, quando preferimos usar asserções do AssertJ (por exemplo, capturando argumentos ou inspecionando listas gravadas no spy) porque queremos customizar a mensagem de erro ou sentimos que asserções explícitas revelam melhor a intenção do teste do que expectativas de mock. (Além disso, em bibliotecas como o MockK, veremos também o uso de spyk() para envolver objetos reais existentes monitorando suas chamadas).

Resumo

Nesta aula, aprendemos seis pontos importantes:

  • Um Spy é um objeto que substitui (ou envolve) uma dependência do Sistema Sob Teste para gravar as interações recebidas.
  • Na definição clássica de Meszaros, um spy manual não configura respostas prévias nem expectativas de argumentos antes da chamada.
  • Em vez de um método verify() fechado, um spy expõe as informações gravadas (contagem de chamadas e argumentos recebidos) para que escrevamos asserções com o AssertJ ao final do teste.
  • Um spy é um dublê de teste usado em cenários específicos onde capturar os argumentos para asserções customizadas ou espionar objetos reais traz mais clareza do que usar um mock convencional.