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:

TaskService.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
37
38
39
40
41
42
package com.testwithspring.starter.mockk

import org.slf4j.LoggerFactory

class TaskService(
    private val repository: TaskRepository? = null
) {

    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
        )

        if (repository == null) {
            return Task(id = sourceTaskId, creator = Creator(creator.userId))
        }

        val found = repository.findById(sourceTaskId)
        val source = found.orElseThrow { NotFoundException() }

        var created = Task.getBuilder()
            .withCreator(creator.userId)
            .withDescription(source.description)
            .withTitle(source.title)
            .build()

        created = repository.save(created)

        LOGGER.info("Created a new task: {}", created)

        return created
    }
}

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

LoggedInUser.kt
Arquivo Real KOTLIN
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():

TaskServiceTest.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
package com.testwithspring.starter.mockk

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

@DisplayName("02 - Criando Dummies e Relaxed Mocks com MockK")
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)
    }

    @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!


Executando Nossos Testes no Gradle

Ao executarmos a suíte TaskServiceTest através do Gradle (./gradlew test --tests TaskServiceTest -i), verificamos a execução dos testes com dublês relaxados e funções utilitárias:

Evidência Real de Execução: ./gradlew test --tests TaskServiceTest -i
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: 16102, address: [fe80::1%1:54330]).
Gradle Daemon started in 1 s 042 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 UP-TO-DATE
> Task :processTestResources NO-SOURCE
> Task :testClasses UP-TO-DATE
> 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.mockk.TaskServiceTest > deve criar dummy dentro do metodo de teste PASSED
com.testwithspring.starter.mockk.TaskServiceTest > deve criar dummy usando o metodo utilitario dummy PASSED

02 - Criando Dummies e Relaxed Mocks com MockK > 2 tests completed, 0 failed, 0 skipped
Finished generating test XML results (0.008 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.015 secs) into: /home/wc/test-with-spring/code/01-starter-junit6/build/reports/tests/test

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

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.