Técnicas Avançadas de Stubbing de Funções

Ao final desta aula, seremos capazes de:

  • Retornar sempre o mesmo objeto todas as vezes em que a função especificada for invocada.
  • Saber como retornar objetos diferentes em invocações subsequentes de uma função.
  • Lançar uma exceção quando a função especificada for invocada.
  • Compreender como especificar uma ação customizada (bloco lambda) que é executada quando uma função configurada (stubbed) é invocada.

Esta aula assume que:

Introdução ao Sistema Sob Teste

Durante esta aula, faremos o stubbing das funções de um repositório que fornece operações de CRUD para objetos Task. Para manter o foco didático o mais simples e direto possível, adicionamos as declarações dos componentes do nosso modelo de dados em um único arquivo de código-fonte em Kotlin 2.4.21 chamado Task.kt. Esse arquivo possui a seguinte estrutura:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
data class Creator(val id: Long)

enum class TaskResolution {
    DUPLICATE,
    DONE,
    WONT_DO
}

enum class TaskStatus {
    CLOSED,
    IN_PROGRESS,
    OPEN
}

data class Task(
    val id: Long?,
    val creator: Creator,
    val title: String,
    val status: TaskStatus,
    val resolution: TaskResolution?
)

A interface TaskRepository possui duas funções das quais faremos o stubbing ao longo desta aula:

  • A função findById() retorna as informações de uma única tarefa utilizando o seu identificador (id) como critério de busca. Se nenhuma tarefa for encontrada no banco de dados, essa função retorna null.
  • A função create() insere uma nova tarefa no banco de dados e retorna as informações da tarefa recém-criada.

O código-fonte da interface TaskRepository é apresentado a seguir:

1
2
3
4
5
6
interface TaskRepository {

    fun findById(id: Long): Task?

    fun create(task: Task): Task
}

A seguir, descobriremos como podemos fazer o stubbing dessas funções utilizando o MockK 1.14 em conjunto com o JUnit Jupiter 6.0.3.

Fazendo o Stubbing de Funções com o MockK

Quando fazemos o stubbing de uma função com o MockK, devemos seguir estes três passos fundamentais:

  1. Invocar a função every (ou coEvery no caso de suspend functions em Coroutines).
  2. Especificar a função que sofrerá o stubbing e os parâmetros de função esperados.
  3. Configurar a resposta da invocação da função.

Leitura Adicional:

Vamos analisar quatro exemplos práticos que demonstram como podemos configurar o comportamento de funções com o MockK.

Exemplo 1: Retornando o Mesmo Objeto em Todas as Invocações da Função

Se quisermos retornar o mesmo objeto todas as vezes em que a função especificada for invocada utilizando os parâmetros esperados, devemos configurar o objeto retornado utilizando a função infixa returns(). Essa função recebe o objeto que deve ser retornado como parâmetro.

Vamos garantir que a função findById() da interface TaskRepository retorne uma tarefa aberta (TaskStatus.OPEN) quando for invocada utilizando o parâmetro de função configurado na constante TASK_ID (1L, criada pelo criador de identificador 5L). Após fazermos o stubbing dessa função, o código-fonte da nossa classe de teste fica da seguinte forma:

 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
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Test

@Tag("unitTest")
@DisplayName("Demonstrates how we can stub functions with MockK")
class TaskRepositoryStubTest {

    companion object {

        private const val CREATOR_ID = 5L
        private const val TASK_ID = 1L
        private const val TITLE = "Example task"
    }

    private lateinit var repository: TaskRepository

    @BeforeEach
    fun createStrictStub() {
        repository = mockk()
    }

    @Test
    @DisplayName("Should return the specified object")
    fun shouldReturnSpecifiedObject() {
        every { repository.findById(TASK_ID) } returns Task(
            id = TASK_ID,
            creator = Creator(id = CREATOR_ID),
            title = TITLE,
            status = TaskStatus.OPEN,
            resolution = null
        )
    }
}

Leitura Adicional:

Exemplo 2: Retornando Objetos Diferentes em Invocações Subsequentes da Função

Se quisermos retornar objetos diferentes em invocações subsequentes de uma mesma função, devemos configurar os objetos retornados utilizando a função returnsMany(), que recebe um objeto List<T> (ou múltiplos argumentos via vararg) como parâmetro. Essa lista contém a sequência de objetos que serão retornados.

Em outras palavras, quando a função configurada é invocada pela primeira vez, o MockK retorna o primeiro item da lista. As invocações subsequentes da função sempre retornam o próximo item da lista na ordem definida. Além disso, se os itens da lista se esgotarem em novas chamadas, o MockK continuará retornando o último item da lista.

Vamos garantir que a função findById() da interface TaskRepository retorne dois objetos Task diferentes quando for invocada utilizando o parâmetro TASK_ID. A primeira invocação retorna uma tarefa aberta (TaskStatus.OPEN) e a segunda invocação retorna uma tarefa que foi fechada (TaskStatus.CLOSED) porque foi concluída (TaskResolution.DONE).

Após fazermos o stubbing dessa função, o código-fonte 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
37
38
39
40
41
42
43
44
45
46
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Test

@Tag("unitTest")
@DisplayName("Demonstrates how we can stub functions with MockK")
class TaskRepositoryStubTest {

    companion object {

        private const val CREATOR_ID = 5L
        private const val TASK_ID = 1L
        private const val TITLE = "Example task"
    }

    private lateinit var repository: TaskRepository

    @BeforeEach
    fun createStrictStub() {
        repository = mockk()
    }

    @Test
    @DisplayName("Should return different object on different invocations")
    fun shouldReturnDifferentObjectsOnDifferentInvocations() {
        every { repository.findById(TASK_ID) } returnsMany listOf(
            Task(
                id = TASK_ID,
                creator = Creator(id = CREATOR_ID),
                title = TITLE,
                status = TaskStatus.OPEN,
                resolution = null
            ),
            Task(
                id = TASK_ID,
                creator = Creator(id = CREATOR_ID),
                title = TITLE,
                status = TaskStatus.CLOSED,
                resolution = TaskResolution.DONE
            )
        )
    }
}

Leitura Adicional:

Exemplo 3: Lançando uma Exceção

Se quisermos lançar uma exceção quando a função especificada for invocada utilizando os parâmetros esperados, devemos configurar a exceção lançada utilizando a função throws(). Essa função recebe a instância do objeto de exceção a ser lançado como parâmetro.

Vamos garantir que a função findById() da interface TaskRepository lance uma RuntimeException quando for invocada utilizando o parâmetro TASK_ID. Após fazermos o stubbing dessa função, o código-fonte da nossa classe de teste terá a seguinte estrutura:

 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
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Test

@Tag("unitTest")
@DisplayName("Demonstrates how we can stub functions with MockK")
class TaskRepositoryStubTest {

    companion object {

        private const val CREATOR_ID = 5L
        private const val TASK_ID = 1L
        private const val TITLE = "Example task"
    }

    private lateinit var repository: TaskRepository

    @BeforeEach
    fun createStrictStub() {
        repository = mockk()
    }

    @Test
    @DisplayName("Should throw an exception")
    fun shouldThrowException() {
        every { repository.findById(TASK_ID) } throws
            RuntimeException("an exception")
    }
}

Nota Importante:

Quando utilizamos essa técnica, jamais devemos quebrar o contrato da API da função que está sofrendo o stubbing (por exemplo, lançando exceções que a implementação real nunca lançaria naquele contexto). Isso é fundamental porque queremos garantir que o sistema sob teste interaja com as suas dependências de maneira realista e fiel ao comportamento de produção.

Leitura Adicional:

Exemplo 4: Configurando a Ação Executada

Se quisermos configurar dinamicamente a ação que é executada quando a função especificada é invocada com os parâmetros esperados, devemos utilizar a função answers(). Quando invocamos essa função, definimos a ação executada por meio de uma expressão lambda que roda no contexto de um MockKAnswerScope. Além disso, se a função configurada possuir um tipo de retorno, nossa expressão lambda também deverá produzir e retornar o objeto correspondente.

Vamos garantir que a função create() da interface TaskRepository atribua o id da tarefa criada antes de retornar um novo objeto Task contendo os dados recebidos por parâmetro. Dentro do bloco answers, podemos inspecionar o primeiro argumento passado para create() por meio da função auxiliar this.firstArg<Task>() (ou ainda usar input.copy(id = 1L) do Kotlin). Após fazermos o stubbing dessa função, o código-fonte 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
37
38
39
40
41
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Test

@Tag("unitTest")
@DisplayName("Demonstrates how we can stub functions with MockK")
class TaskRepositoryStubTest {

    companion object {

        private const val CREATOR_ID = 5L
        private const val TASK_ID = 1L
        private const val TITLE = "Example task"
    }

    private lateinit var repository: TaskRepository

    @BeforeEach
    fun createStrictStub() {
        repository = mockk()
    }

    @Test
    @DisplayName("Should configure the executed action")
    fun shouldConfigureExecutedAction() {
        every { repository.create(any()) } answers {
            val input = this.firstArg<Task>()

            Task(
                id = 1L,
                creator = input.creator,
                title = input.title,
                status = input.status,
                resolution = input.resolution
            )
        }
    }
}

Leitura Adicional:

Resumo

Esta aula nos ensinou cinco conceitos essenciais sobre stubbing com o MockK:

  • Quando queremos fazer o stubbing de uma função com o MockK, devemos iniciar a configuração utilizando a função every().
  • Se quisermos retornar sempre o mesmo objeto todas as vezes em que a função especificada for invocada utilizando os parâmetros esperados, devemos configurar o objeto retornado com a função returns().
  • Se quisermos retornar objetos diferentes em invocações subsequentes da função, devemos configurar a lista de objetos retornados utilizando a função returnsMany().
  • Se quisermos lançar uma exceção quando a função especificada for invocada com os parâmetros esperados, devemos configurar a exceção lançada utilizando a função throws().
  • Se quisermos configurar uma ação customizada que é executada dinamicamente quando a função especificada é invocada com os parâmetros esperados, devemos utilizar a função answers().