Introdução a Fakes em Memória

Ao concluirmos esta aula, nós:

  • Saberemos o que é um Fake.
  • Compreenderemos como objetos Fake funcionam por dentro.
  • Compreenderemos quando devemos utilizar objetos Fake em vez de Stubs ou Mocks.

[!NOTE] Esta aula assume que:


O Que É um Fake?

Um Fake é um objeto utilizado para substituir uma dependência externa do Sistema Sob Teste (SUT). Todo objeto fake deve cumprir dois requisitos essenciais:

  • Um fake deve fornecer a mesma API que a dependência externa substituída: Se a dependência externa for uma classe, nossa classe fake deve estendê-la e sobrescrever todos os seus métodos. Se a dependência substituída implementar uma interface, nossa classe fake deve implementar essa mesma interface.
  • Um fake fornece uma implementação leve e funcional da dependência real: Ele age exatamente como a dependência verdadeira aos olhos de quem o chama, mas faz concessões (como armazenar dados em memória RAM) que o tornam inadequado para uso no ambiente de produção.

Agora demonstraremos essas regras na prática construindo um fake 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 TaskRepository. Essa interface declara dois métodos que são utilizados para salvar objetos Task no banco de dados e consultá-los de volta:

  • O método findById() recebe o ID da tarefa solicitada como parâmetro e retorna a tarefa encontrada (Task?).
  • O método save() recebe a tarefa a ser salva como parâmetro, persiste-a no banco de dados e retorna as informações da tarefa salva (já com seu ID gerado).

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

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

interface TaskRepository {

    fun findById(id: Long): Task?

    fun save(saved: Task): Task
}

Vamos descobrir como podemos criar um objeto fake simples capaz de substituir objetos TaskRepository reais.


Criando um Objeto Fake Simples

Podemos criar um objeto fake que substitui o TaskRepository real seguindo estes quatro passos:

Passo 1: Criar a classe TaskRepositoryFake

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

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

1
2
3
class TaskRepositoryFake : TaskRepository {

}

[!TIP] Nomenclatura de Classes Fake Ao nomear classes fake, uma ótima prática é acrescentar o sufixo Fake ao nome do tipo do objeto “real”. Como nossa classe substitui objetos TaskRepository, seu nome deve ser TaskRepositoryFake.

Passo 2: Criar o mecanismo de armazenamento em memória

Segundo, criamos um mecanismo interno que imita o comportamento do banco de dados real:

  1. Adicionamos um campo privado private var nextId: Long = 1L à nossa classe fake. Esse campo contém o ID sequencial que será atribuído à próxima tarefa salva em nosso banco de dados em memória.
  2. Adicionamos um campo private val tasks = mutableMapOf<Long, Task>() à nossa classe fake. Esse MutableMap armazena os objetos Task salvos indexados pelo seu ID.

O código-fonte da classe TaskRepositoryFake fica assim:

1
2
3
4
5
class TaskRepositoryFake : TaskRepository {

    private var nextId: Long = 1L
    private val tasks: MutableMap<Long, Task> = HashMap()
}

Passo 3: Implementar o método findById()

Terceiro, implementamos o método findById() da interface TaskRepository. Fazemos isso simplesmente buscando o objeto Task solicitado dentro do nosso Map (tasks[id]) e retornando o objeto encontrado.

Após implementarmos esse método, o código-fonte da classe TaskRepositoryFake fica assim:

1
2
3
4
5
6
7
8
9
class TaskRepositoryFake : TaskRepository {

    private var nextId: Long = 1L
    private val tasks: MutableMap<Long, Task> = HashMap()

    override fun findById(id: Long): Task? {
        return tasks[id]
    }
}

Passo 4: Implementar o método save()

Quarto, implementamos o método save() da interface TaskRepository seguindo estes passos:

  1. Atribuímos o ID correto (nextId) ao objeto Task que está sendo salvo.
  2. Inserimos a tarefa no nosso Map (tasks[id] = saved).
  3. Incrementamos o valor do campo nextId em 1.
  4. Retornamos o objeto Task salvo.

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

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

class TaskRepositoryFake : TaskRepository {

    private var nextId: Long = 1L
    private val tasks: MutableMap<Long, Task> = HashMap()

    override fun findById(id: Long): Task? {
        return tasks[id]
    }

    override fun save(saved: Task): Task {
        val id = nextId
        saved.id = id

        tasks[id] = saved
        nextId++

        return saved
    }
}

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


Utilizando o Nosso Objeto Fake

Podemos criar uma classe de teste que utiliza o nosso objeto fake seguindo estes dois passos:

Passo 1: Criar uma nova instância do Fake no @BeforeEach

Primeiro, criamos uma nova instância do nosso objeto fake antes que o método de teste seja invocado, adicionando um método @BeforeEach à nossa classe de teste:

 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 TaskRepositoryFakeTest {

    companion object {
        private const val TITLE = "task"
    }

    private lateinit var repository: TaskRepository

    @BeforeEach
    fun createRepository() {
        repository = TaskRepositoryFake()
    }
}

[!NOTE] Na prática do dia a dia, geralmente não criamos um novo objeto fake manualmente em testes unitários isolados de método a método (onde Stubs e Mocks são mais diretos). O motivo ficará óbvio na próxima seção!

Passo 2: Escrever o método de teste saveAndFindTask()

Segundo, implementamos o nosso método de teste seguindo estes passos:

  1. Criamos um novo objeto Task.
  2. Salvamos o objeto Task criado “no banco de dados” (repository.save(newTask)).
  3. Verificamos se o objeto Task retornado pelo save() contém o ID (1L) e o título corretos.
  4. Buscamos o objeto Task “do banco de dados” (repository.findById(...)) usando o ID da tarefa salva como critério de busca.
  5. Verificamos se o objeto Task encontrado contém as informações corretas.

Após implementarmos o método de teste, o código-fonte completo da nossa 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
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.Test

class TaskRepositoryFakeTest {

    companion object {
        private const val ID = 1L
        private const val TITLE = "task"
    }

    private lateinit var repository: TaskRepository

    @BeforeEach
    fun createRepository() {
        repository = TaskRepositoryFake()
    }

    @Test
    fun `deve salvar e encontrar a tarefa no repositorio fake`() {
        val newTask = TaskBuilder()
            .withTitle(TITLE)
            .build()

        val saved = repository.save(newTask)
        assertThat(saved.id).isEqualByComparingTo(ID)
        assertThat(saved.title).isEqualTo(TITLE)

        val found = repository.findById(saved.id!!)
        assertThat(found).isNotNull
        assertThat(found!!.id).isEqualByComparingTo(ID)
        assertThat(found.title).isEqualTo(TITLE)
    }
}

Este exemplo demonstrou duas características fundamentais:

  1. Nosso método de teste interage com o fake exatamente como interagiria com o TaskRepository real (sem precisar configurar respostas fixas com every { ... } returns ...), porque o fake possui lógica de estado funcional própria.
  2. Nenhuma pessoa em sã consciência usaria esse TaskRepositoryFake (baseado em um HashMap não sincronizado em RAM que perde dados ao reiniciar e não suporta transações) em código de produção!

Agora vamos descobrir quando devemos usar objetos fake.


Quando Devemos Usar Objetos Fake?

Ao decidirmos se devemos ou não usar um fake, devemos lembrar que um fake fornece uma implementação leve da dependência real. Isso traz dois grandes benefícios:

  • Um fake é muito mais fácil de configurar do que a dependência real: Normalmente, fakes exigem zero configuração externa, o que é crucial quando estamos escrevendo testes de integração ou testes end-to-end que já possuem configurações complexas.
  • Um fake é ordens de grandeza mais rápido do que a dependência real: Como um fake roda em memória ou localmente sem I/O pesado, podemos fazer concessões de arquitetura que seriam impossíveis em produção.

Para saber exatamente quando escolher um Fake, vamos compará-lo diretamente com Stubs e Mocks:

  • Um Stub nos ajuda a controlar exatamente qual resposta será retornada quando seu método for invocado em um cenário pontual.
  • Um Mock nos ajuda a controlar o comportamento da dependência substituída e verificar as interações exatas ocorridas entre o Sistema Sob Teste e o mock.
  • Um Fake age como a dependência real de ponta a ponta: não queremos configurar manualmente a resposta de cada chamada nem queremos verificar interações de métodos; queremos apenas substituir uma dependência problemática por uma implementação funcional leve.

Por essa razão, devemos usar Fakes principalmente quando estamos escrevendo testes de integração ou testes End-to-End (ou testes de fluxos de casos de uso completos) e queremos substituir uma dependência externa que é difícil demais de configurar ou lenta demais para rodar a cada ciclo:

  • Por exemplo, substituir um servidor de e-mail SMTP real por um servidor SMTP fake em memória, substituir um gateway de pagamento externo por um servidor HTTP fake (como WireMock), ou usar um banco de dados em memória leve quando apropriado.

Resumo

Nesta aula, aprendemos quatro pontos essenciais:

  • Um Fake é um objeto que substitui uma dependência externa do Sistema Sob Teste.
  • Um Fake deve fornecer a mesma API pública que a dependência externa substituída.
  • Um Fake age como a implementação real (mantendo estado funcional coerente), mas jamais deve ser usado no ambiente de produção.
  • Devemos usar um Fake quando configurar a dependência externa real for inviável ou quando o uso da dependência real deixar nossos testes excessivamente lentos.