Introdução a Mocks

Ao concluirmos esta aula, nós:

  • Saberemos o que é um Mock.
  • Compreenderemos como objetos Mock funcionam por dentro.
  • Compreenderemos quando devemos utilizar Mocks em nossos testes unitários.

[!NOTE] Esta aula assume que:


O Que É um Mock?

Um Mock é um objeto utilizado para substituir uma dependência externa do Sistema Sob Teste (SUT). Todo mock deve cumprir cinco requisitos fundamentais:

  • Um mock deve fornecer a mesma API que a dependência externa substituída: Se a dependência externa for uma classe, nosso mock deve estendê-la e sobrescrever seus métodos. Se a dependência substituída implementar uma interface, nosso mock deve implementar essa mesma interface.
  • Devemos ser capazes de configurar a resposta retornada quando um método do mock é invocado: Podemos configurar o mock tanto para retornar um objeto quanto para lançar uma exceção.
  • Um mock retorna a resposta configurada todas as vezes em que seu método é invocado.
  • Devemos ser capazes de configurar os valores ou objetos esperados que devem ser passados como parâmetros quando um método do mock for invocado.
  • Um mock fornece uma maneira de verificar as interações que acontecem entre o Sistema Sob Teste e o próprio mock.

Agora demonstraremos essas cinco regras na prática construindo um objeto mock simples passo a passo. Vamos começar analisando a dependência do 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 um método chamado deleteById(), que recebe o ID da tarefa a ser excluída como parâmetro e retorna o objeto Task excluído.

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

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

interface TaskRepository {

    fun deleteById(id: Long): Task?
}

Vamos descobrir como podemos criar um mock simples para a interface TaskRepository.


Criando um Mock Simples

Podemos criar um mock capaz de substituir um objeto TaskRepository real seguindo estes cinco passos:

Passo 1: Criar a classe TaskRepositoryMock

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 mock fica assim:

1
2
3
class TaskRepositoryMock : TaskRepository {

}

[!TIP] Nomenclatura de Classes Mock Ao nomear classes mock manuais, acrescentamos o sufixo Mock ao tipo do objeto real. Como nossa classe substitui objetos TaskRepository, seu nome deve ser TaskRepositoryMock.

Passo 2: Configurar o objeto Task retornado

Segundo, escrevemos um método (ou propriedade) utilizado para configurar o objeto Task que será retornado quando o método deleteById() for invocado. Implementamos isso seguindo estes passos:

  1. Adicionamos um campo privado private var returnedTask: Task? = null à classe TaskRepositoryMock. Esse campo armazena a referência para o objeto Task que será retornado.
  2. Adicionamos um método setReturnedTask(returnedTask: Task?) à classe TaskRepositoryMock.
  3. Implementamos o método setReturnedTask() atribuindo o objeto recebido ao campo returnedTask.

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

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

    private var returnedTask: Task? = null

    fun setReturnedTask(returnedTask: Task?) {
        this.returnedTask = returnedTask
    }
}

Passo 3: Configurar o argumento id esperado

Terceiro, escrevemos um método utilizado para configurar o ID esperado que deve ser passado como parâmetro para o método deleteById(). Implementamos isso seguindo estes passos:

  1. Adicionamos um campo privado private var expectedIdArgument: Long? = null à classe TaskRepositoryMock. Esse campo armazena o ID que esperamos receber quando deleteById() for invocado.
  2. Adicionamos um método setExpectedIdArgument(expectedIdArgument: Long) à classe TaskRepositoryMock.
  3. Implementamos o método setExpectedIdArgument() armazenando o ID esperado no campo expectedIdArgument.

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class TaskRepositoryMock : TaskRepository {

    private var expectedIdArgument: Long? = null
    private var returnedTask: Task? = null

    fun setExpectedIdArgument(expectedIdArgument: Long) {
        this.expectedIdArgument = expectedIdArgument
    }

    fun setReturnedTask(returnedTask: Task?) {
        this.returnedTask = returnedTask
    }
}

Passo 4: Implementar o método deleteById() registrando a chamada

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

  1. Adicionamos um campo privado private var actualIdArgument: Long? = null à nossa classe mock. Esse campo armazena o ID real que foi passado como parâmetro quando o método deleteById() foi invocado.
  2. Adicionamos um campo privado private var deleteByIdCalled: Boolean = false à nossa classe mock. Esse campo é utilizado para determinar se o método deleteById() foi efetivamente invocado pelo Sistema Sob Teste.
  3. Implementamos o método deleteById(), atualizando os valores de actualIdArgument e deleteByIdCalled = true, e retornando o objeto returnedTask configurado.

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
class TaskRepositoryMock : TaskRepository {

    private var actualIdArgument: Long? = null
    private var deleteByIdCalled: Boolean = false
    private var expectedIdArgument: Long? = null
    private var returnedTask: Task? = null

    override fun deleteById(id: Long): Task? {
        actualIdArgument = id
        deleteByIdCalled = true

        return returnedTask
    }

    fun setExpectedIdArgument(expectedIdArgument: Long) {
        this.expectedIdArgument = expectedIdArgument
    }

    fun setReturnedTask(returnedTask: Task?) {
        this.returnedTask = returnedTask
    }
}

Passo 5: Implementar o método verify()

Quinto, implementamos o método utilizado para verificar que o método deleteById() foi invocado e que o ID esperado foi passado como parâmetro:

  1. Adicionamos um método verify() à classe TaskRepositoryMock. Esse método não recebe parâmetros e não retorna nada.
  2. Implementamos o método escrevendo as asserções necessárias com o AssertJ para validar tanto deleteByIdCalled quanto actualIdArgument.

Após implementarmos o método verify(), o código-fonte completo da nossa classe TaskRepositoryMock 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
37
38
39
40
41
42
package com.testwithspring.starter.testdoubles

import org.assertj.core.api.Assertions.assertThat

class TaskRepositoryMock : TaskRepository {

    private var actualIdArgument: Long? = null
    private var deleteByIdCalled: Boolean = false
    private var expectedIdArgument: Long? = null
    private var returnedTask: Task? = null

    override fun deleteById(id: Long): Task? {
        actualIdArgument = id
        deleteByIdCalled = true

        return returnedTask
    }

    fun setExpectedIdArgument(expectedIdArgument: Long) {
        this.expectedIdArgument = expectedIdArgument
    }

    fun setReturnedTask(returnedTask: Task?) {
        this.returnedTask = returnedTask
    }

    fun verify() {
        assertThat(deleteByIdCalled)
            .overridingErrorMessage(
                "Expected that deleteById() was called but it was not"
            )
            .isTrue()

        assertThat(actualIdArgument)
            .overridingErrorMessage(
                "Invalid id argument. Expected: %d but was: %d",
                expectedIdArgument,
                actualIdArgument
            )
            .isEqualByComparingTo(expectedIdArgument)
    }
}

Criamos um mock simples! A seguir, escreveremos dois métodos de teste que demonstram como o nosso mock funciona.


Utilizando o Nosso Objeto Mock

Se quisermos utilizar um objeto mock em nossos testes, devemos compreender que objetos mock não são stateless (eles gravam o histórico de chamadas recebidas). Isso significa que devemos criar um novo objeto mock 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 TaskRepositoryMockTest {

    companion object {
        private const val TASK_ID = 44L
    }

    private lateinit var repository: TaskRepositoryMock

    @BeforeEach
    fun createMock() {
        repository = TaskRepositoryMock()
    }
}

Agora podemos escrever dois métodos de teste que demonstram como nosso objeto mock funciona:

Primeiro Teste: Verificar a Chamada do Método e o Argumento Passado

Escrevemos um método de teste que verifica se o método deleteById() foi invocado e se o ID correto foi passado como parâmetro seguindo estes passos:

  1. Configuramos o ID que deve ser passado como parâmetro quando o método deleteById() for invocado (setExpectedIdArgument).
  2. Invocamos o método deleteById() da interface TaskRepository.
  3. Verificamos (repository.verify()) se o método deleteById() foi invocado e se o ID esperado foi recebido.

O código-fonte desse 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
package com.testwithspring.starter.testdoubles

import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test

class TaskRepositoryMockTest {

    companion object {
        private const val TASK_ID = 44L
    }

    private lateinit var repository: TaskRepositoryMock

    @BeforeEach
    fun createMock() {
        repository = TaskRepositoryMock()
    }

    @Test
    fun `deleteById deve invocar o metodo correto com o id correto`() {
        repository.setExpectedIdArgument(TASK_ID)
        repository.deleteById(TASK_ID)
        repository.verify()
    }
}

Segundo Teste: Verificar o Retorno da Tarefa Excluída

Escrevemos um segundo método de teste que verifica se o objeto Task excluído é retornado quando o método deleteById() é invocado seguindo estes passos:

  1. Criamos um novo objeto Task.
  2. Configuramos o objeto Task que deve ser retornado quando o método deleteById() for invocado (setReturnedTask).
  3. Invocamos o método deleteById() da interface TaskRepository.
  4. Escrevemos uma asserção que verifica se o objeto Task correto foi retornado.

O código-fonte completo com ambos os testes 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
37
38
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 TaskRepositoryMockTest {

    companion object {
        private const val TASK_ID = 44L
    }

    private lateinit var repository: TaskRepositoryMock

    @BeforeEach
    fun createMock() {
        repository = TaskRepositoryMock()
    }

    @Test
    fun `deleteById deve invocar o metodo correto com o id correto`() {
        repository.setExpectedIdArgument(TASK_ID)
        repository.deleteById(TASK_ID)
        repository.verify()
    }

    @Test
    fun `deleteById deve retornar a tarefa excluida`() {
        val deleted = TaskBuilder()
            .withId(TASK_ID)
            .build()

        repository.setReturnedTask(deleted)

        val returned = repository.deleteById(TASK_ID)
        assertThat(returned).isSameAs(deleted)
    }
}

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

  • Nos permite configurar a resposta que é retornada quando seu método é invocado.
  • Retorna a mesma resposta todas as vezes em que seu método é invocado.
  • Nos permite configurar os valores ou objetos esperados que devem ser passados como parâmetros de método.
  • Fornece uma maneira de verificar as interações ocorridas entre o Sistema Sob Teste e o mock em questão.

Mesmo que nosso objeto mock manual seja simples, exatamente os mesmos princípios fundamentais se aplicam quando utilizamos uma biblioteca moderna de mocking como o MockK.

A seguir, descobriremos quando devemos usar objetos mock.


Quando Devemos Usar Mocks?

Como vimos na aula anterior, um Sistema Sob Teste normalmente possui dois tipos de dependências externas:

  1. Dependências que consultam informações utilizadas pelo Sistema Sob Teste (Fontes de Dados).
  2. Dependências que disparam uma ação que possui um “efeito colateral” (Side Effect): salvar ou excluir dados no banco de dados, enviar uma requisição HTTP para uma API externa, disparar um evento de domínio etc.

Como podemos verificar se uma interação aconteceu entre o Sistema Sob Teste e um mock, devemos usar mocks nas situações em que precisamos comprovar que o Sistema Sob Teste realmente invocou (ou deixou de invocar) um método da dependência externa:

  • Se a dependência for apenas uma fonte de dados (datasource), não precisamos verificar interações — basta usar um Stub e fazer asserções sobre o retorno do Sistema Sob Teste.
  • Se a dependência disparar uma ação que possui um efeito colateral (como deleteById, save, sendEmail, publishEvent), precisamos verificar que o Sistema Sob Teste invocou o método correto da dependência externa com os argumentos corretos (ou que não o invocou em caso de erro). Sem essa verificação, seria impossível garantir que o efeito colateral desejado ocorreu.

Em outras palavras: devemos substituir uma dependência externa do Sistema Sob Teste por um Mock sempre que essa dependência externa disparar uma ação que produz um “efeito colateral”.


Resumo

Nesta aula, aprendemos quatro conceitos fundamentais:

  • Um Mock é um objeto que substitui uma dependência externa do Sistema Sob Teste e retorna a resposta configurada quando seu método é invocado.
  • Um Mock nos permite configurar os valores ou objetos esperados que devem ser passados como parâmetros quando um método do mock é chamado.
  • Um Mock fornece um mecanismo para verificar as interações ocorridas entre o Sistema Sob Teste e o próprio mock.
  • Devemos substituir uma dependência externa do Sistema Sob Teste por um Mock quando essa dependência externa disparar uma ação que causa um “efeito colateral” (side effect).