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:

TaskAuditLog.kt
Arquivo Real KOTLIN
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:

TaskAuditLogSpy.kt
Arquivo Real KOTLIN
 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:

TaskRepositorySpyTest.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
32
33
34
35
36
package com.testwithspring.starter.testdoubles

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

@DisplayName("Testes de Spy com TaskAuditLogSpy")
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)
    }
}

Executando o Teste no Gradle

Ao executarmos o teste unitário com Gradle (./gradlew test --tests "TaskRepositorySpyTest" --info), capturamos a seguinte evidência real de execução:

Evidência Real de Execução (Gradle --info)
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: 17201, address: [fe80::1%1:54335]).
Gradle Daemon started in 1 s 012 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
Compiling with Kotlin compiler version 2.4.21 (JVM target 21)
Compiling Kotlin files from [/home/wc/test-with-spring/code/01-starter-junit6/src/test/kotlin]

> Task :processTestResources NO-SOURCE
> Task :testClasses
> 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.testdoubles.TaskRepositorySpyTest > logTask deve registrar a chamada e a tarefa auditada PASSED

TaskRepositorySpyTest > 1 test completed, 0 failed, 0 skipped
Finished generating test XML results (0.007 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.012 secs) into: /home/wc/test-with-spring/code/01-starter-junit6/build/reports/tests/test

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

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.