Criando Dummies e Relaxed Mocks com MockK

Ao concluirmos esta aula, nós:

  • Saberemos como criar objetos Dummy utilizando o MockK (mockk<T>(relaxed = true)).
  • Compreenderemos o que acontece quando o Sistema Sob Teste invoca um método de um Dummy (por exemplo, em uma instrução de log) e por que lançar exceções em Dummies torna os testes frágeis.
  • Saberemos como destacar de forma cristalina no código que o dublê de teste criado é um Dummy criando a função auxiliar dummy<T>().

[!NOTE] Esta aula assume que:


Introdução ao Sistema Sob Teste (SUT)

O nosso Sistema Sob Teste (TaskService) possui um método chamado createCopyOf(), que cria uma cópia de uma tarefa existente e retorna uma referência para o novo objeto Task criado. Esse método recebe dois parâmetros:

  1. O ID da tarefa de origem (sourceTaskId: Long).
  2. Um objeto LoggedInUser (creator: LoggedInUser) que contém as informações do usuário autenticado que será registrado como criador da nova tarefa.

Como esta aula se concentra na criação de dummies com o MockK, a implementação completa do método createCopyOf() não é relevante agora. A parte relevante da classe TaskService é a seguinte:

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

import org.slf4j.LoggerFactory

class TaskService {

    companion object {
        private val LOGGER = LoggerFactory.getLogger(TaskService::class.java)
    }

    fun createCopyOf(sourceTaskId: Long?, creator: LoggedInUser?): Task? {
        requireNotNull(sourceTaskId) { "sourceTaskId cannot be null" }
        requireNotNull(creator) { "creator cannot be null" }

        LOGGER.info(
            "Creating a copy of the task: {} with creator: {}",
            sourceTaskId,
            creator.username
        )

        // O restante da implementação é irrelevante para este exemplo.
        return null
    }
}

A classe LoggedInUser contém as informações de um usuário autenticado, e seu código-fonte é o seguinte:

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

class LoggedInUser(
    val userId: Long,
    val username: String
)

Vamos descobrir como podemos criar dummies com o MockK.


Criando Dummies com o MockK

A primeira coisa que precisamos compreender é que nem o MockK nem o Mockito diferenciam estruturalmente dummies, stubs e mocks na hora da criação:

  • No Mockito, existe apenas o método Mockito.mock(T::class.java) para criar dummies, stubs e mocks.
  • No MockK, existe a função mockk<T>() para criar dummies, stubs e mocks, e qualquer dublê criado com essa função tem potencial técnico para atuar como um mock.

Por essa razão, devemos ter cuidado redobrado e garantir que o propósito do nosso dublê de teste fique cristalino para qualquer pessoa que leia o nosso código de teste.

Como vimos anteriormente, a primeira forma de destacar que um dublê de teste é um dummy é dar um nome descritivo à variável que o armazena (como notImportant). Por exemplo, um método de teste que cria um dummy de LoggedInUser 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.mockk

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

class TaskServiceTest {

    companion object {
        private const val SOURCE_TASK_ID = 99L
    }

    private lateinit var service: TaskService

    @BeforeEach
    fun configureSystemUnderTest() {
        service = TaskService()
    }

    @Test
    fun `deve criar dummy dentro do metodo de teste`() {
        val notImportant = mockk<LoggedInUser>(relaxed = true)
        service.createCopyOf(SOURCE_TASK_ID, notImportant)
    }
}

[!NOTE] Tecnicamente falando, a constante SOURCE_TASK_ID também atua como um dummy neste exemplo específico, pois seu valor não altera o resultado aqui. Contudo, usamos um valor realista (99L) porque ele será utilizado nas próximas aulas deste tópico.

Agora surge uma pergunta fundamental:

O que acontece se o Sistema Sob Teste invocar um método (ou acessar uma propriedade) do nosso objeto Dummy?

Aqui existe uma diferença importante entre o comportamento padrão do Mockito e o comportamento padrão do MockK, e compreendê-la é essencial!

No Mockito, quando criamos um dublê sem configurar uma resposta padrão (mock(LoggedInUser.class)), ele é “relaxado” por padrão: se alguém chamar creator.getUsername(), o Mockito retorna um valor “vazio” (null, 0, false ou uma coleção vazia).

Já no MockK, um mockk<LoggedInUser>() simples é estrito por padrão (strict): se qualquer método ou propriedade dele for invocado sem ter sido configurado com every { ... }, o MockK lança imediatamente um MockKException: no answer found for: LoggedInUser.getUsername()!

Como um dummy é irrelevante para o nosso caso de teste, poderíamos pensar a princípio: “Não seria uma boa ideia deixar o dummy estrito (ou configurar um Answer no Mockito para lançar RuntimeException) para garantir que o teste falhe caso o Sistema Sob Teste toque no nosso dummy?”

Na verdade, essa é uma péssima ideia! Vamos entender exatamente o porquê.

Suponha que no Mockito configurássemos um Answer que lança RuntimeException("The system under test invoked a dummy.") — ou que no MockK criássemos o dummy como mockk<LoggedInUser>() estrito (sem relaxed = true):

1
2
3
4
5
6
7
8
// Abordagem no Mockito configurando Answer que lança exceção (ou mockk() estrito sem relaxed = true):
@Test
fun `criar dummy estrito falha ao passar pelo log`() {
    // No MockK, mockk<LoggedInUser>() sem 'relaxed = true' lança MockKException
    // se qualquer propriedade for lida!
    val notImportant = mockk<LoggedInUser>()
    service.createCopyOf(SOURCE_TASK_ID, notImportant)
}

Quando executamos esse teste, descobrimos que ele falha com uma exceção! Isso significa que o objeto LoggedInUser deixou de ser um dummy para o nosso cenário de teste?

Não! O nosso caso de teste falhou única e exclusivamente porque o Sistema Sob Teste escreve uma mensagem de log diagnóstica no início do método:

1
2
3
4
5
LOGGER.info(
    "Creating a copy of the task: {} with creator: {}",
    sourceTaskId,
    creator.username
)

Nós não estamos interessados nessa linha de log durante o teste de negócio, e definitivamente não queremos que uma simples linha de LOGGER.info que acessa creator.username quebre nossos testes ou nos obrigue a fazer stubbing manual de um objeto irrelevante!

É exatamente por isso que:

  1. No Mockito, jamais devemos configurar um Answer que lance exceção em um dummy, pois o retorno padrão “vazio” do Mockito (null / "" / 0) permite que instruções de log passem sem quebrar o teste.
  2. No MockK, ao criarmos um Dummy, devemos utilizar mockk<T>(relaxed = true)! Com relaxed = true, o MockK retorna valores padrão seguros e silenciosos ("" para String, 0L para Long, false para Boolean, listas vazias etc.) caso o Sistema Sob Teste acesse alguma propriedade do dummy em uma linha de log, evitando que nossos testes fiquem frágeis (brittle).

[!NOTE] E se passarmos o objeto inteiro para o Logger (LOGGER.info(..., creator))? Mesmo que o logger receba o objeto inteiro creator em vez de creator.username, o SLF4J/Logback invocará creator.toString(). Portanto, manter o dummy relaxado (relaxed = true) garante que nem toString() nem leituras de propriedades de log como creator.userId ou creator.username quebrem o teste indevidamente.

Já sabemos como criar dummies seguros com o MockK. Contudo, podemos tornar esse processo ainda mais expressivo e legível! Vamos descobrir como.


Indo Além: Criando o Object Mother TestDoubles.dummy()

Como dissemos anteriormente, tanto o MockK (mockk()) quanto o Mockito (mock()) usam a palavra “mock” no nome da função que cria dublês de teste. O problema é que ler mockk<LoggedInUser>(relaxed = true) no meio do teste ainda contém a palavra mockk, o que pode sugerir visualmente que o objeto criado é um mock (e não um dummy).

Isso pode parecer um detalhe pequeno, mas se quisermos destacar com clareza cirúrgica o papel de cada dublê de teste em nossa suíte, podemos resolver esse problema facilmente seguindo estes três passos:

Passo 1: Criar o objeto utilitário TestDoubles

Primeiro, criamos um object TestDoubles (ou arquivo de funções utilitárias de teste). Em Kotlin, um object já é final e possui construtor privado automaticamente, impedindo instanciação ou herança indevida:

1
2
3
4
5
package com.testwithspring.starter.mockk

object TestDoubles {

}

Passo 2: Adicionar o método fábrica dummy()

Segundo, adicionamos uma função fábrica genérica (usando inline e reified T : Any do Kotlin, além da sobrecarga com KClass / Class se desejado) chamada dummy() dentro de TestDoubles. Essa função delega internamente para mockk<T>(relaxed = true) e retorna o dublê de teste criado:

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

import io.mockk.mockk

object TestDoubles {

    inline fun <reified T : Any> dummy(): T {
        return mockk<T>(relaxed = true)
    }

    @JvmStatic
    fun <T : Any> dummy(dummyClass: Class<T>): T {
        return mockkClass(dummyClass.kotlin, relaxed = true)
    }
}

Passo 3: Utilizar dummy() em nosso método de teste

Terceiro, modificamos nosso método de teste para criar o objeto dummy utilizando nossa nova função dummy():

 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.mockk

import com.testwithspring.starter.mockk.TestDoubles.dummy
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test

class TaskServiceTest {

    companion object {
        private const val SOURCE_TASK_ID = 99L
    }

    private lateinit var service: TaskService

    @BeforeEach
    fun configureSystemUnderTest() {
        service = TaskService()
    }

    @Test
    fun `deve criar dummy usando o metodo utilitario dummy`() {
        val notImportant: LoggedInUser = dummy()
        service.createCopyOf(SOURCE_TASK_ID, notImportant)
    }
}

Olhe para a linha val notImportant: LoggedInUser = dummy() (ou val notImportant = dummy<LoggedInUser>()):

Agora utilizamos dois mecanismos complementares para destacar o fato de que o dublê de teste criado é um dummy:

  1. O nome da variável (notImportant ou notUsed) que referencia o nosso dummy.
  2. A criação do objeto invocando um método que se chama explicitamente dummy() (em vez de mockk()).

Fazer esse pequeno esforço extra vale muito a pena porque, na prática, muitas equipes chamam qualquer dublê de teste indiscriminadamente de “mock”. Quando usamos val notImportant = dummy<LoggedInUser>(), eliminamos qualquer ambiguidade e comunicamos nossa intenção imediatamente para quem lê o teste!


Resumo

Nesta aula, aprendemos três lições práticas:

  • As bibliotecas de mocking (MockK e Mockito) usam a mesma função base (mockk() / mock()) para criar dummies, stubs e mocks.
  • Um dummy não deve lançar exceção caso uma instrução inocente de log acesse uma de suas propriedades; por isso, no MockK, criamos dummies com relaxed = true (e no Mockito mantemos a resposta padrão sem lançar exceções).
  • Não devemos invocar mockk() diretamente na classe de teste quando quisermos criar um dummy: encapsular a criação em uma função semântica dummy<T>() combinada com um nome de variável como notImportant torna a intenção do teste 100% explícita.