Comportamento Estrito vs Relaxado de Stubs no MockK

Ao concluirmos esta aula, nós:

  • Saberemos o que é um Strict Stub (Stub Estrito).
  • Compreenderemos como criar Strict Stubs tanto no MockK quanto no Mockito.
  • Seremos capazes de configurar um Strict Stub para retornar um objeto, lançar uma exceção ou executar uma resposta dinâmica.
  • Compreenderemos quando devemos usar Strict Stubs e como o MockK eliminou a principal desvantagem sintática que existia no Mockito clássico.

[!NOTE] Esta aula assume que:


O Que É e Como Criar um Strict Stub?

Antes de criarmos um strict stub, precisamos definir com exatidão o que esse termo significa:

Um Strict Stub (Stub Estrito) é um stub que faz o teste falhar imediatamente caso o Sistema Sob Teste invoque um método inesperado.

Em outras palavras, se o Sistema Sob Teste invocar qualquer método cujo comportamento não tenha sido explicitamente configurado para aquele teste (ou invocá-lo com argumentos diferentes dos configurados), um strict stub deve abortar o teste lançando uma exceção!

Vamos ver como isso funciona no Mockito clássico e como o MockK tornou os strict stubs cidadãos de primeira classe em Kotlin:

1. Como um Strict Stub é criado no Mockito vs. MockK

No Mockito clássico, um dublê criado com Mockito.mock(stubClass) é relaxado por padrão (ele retorna null, 0, false ou coleções vazias quando um método não configurado é chamado). Para criar um strictStub no Mockito, é necessário sobrescrever o Answer padrão passando uma implementação que lança RuntimeException("SUT invoked an unexpected method of a stub"):

1
2
3
4
5
6
// Como era necessário fazer no Mockito (Java):
fun <T> strictStubMockito(stubClass: Class<T>): T {
    return org.mockito.Mockito.mock(stubClass) {
        throw RuntimeException("SUT invoked an unexpected method of a stub")
    }
}

Já no MockK, os criadores da biblioteca perceberam que falhar rapidamente diante de chamadas não configuradas evita bugs silenciosos. Por isso, no MockK, mockk<T>() (com relaxed = false, que é o padrão!) JÁ É um Strict Stub nativo! Se o Sistema Sob Teste chamar qualquer método não configurado em um mockk<T>(), o MockK lança automaticamente um MockKException: no answer found for: ....

Vamos adicionar o método fábrica strictStub() ao nosso objeto utilitário TestDoubles para deixar essa distinção 100% explícita em nosso código:

  1. Adicionamos a função strictStub() ao objeto TestDoubles.
  2. Implementamos strictStub() invocando mockk<T>() no modo estrito padrão (relaxed = false).

Após implementarmos strictStub(), o código-fonte do objeto TestDoubles fica assim:

TestDoubles.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 io.mockk.mockk
import io.mockk.mockkClass

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)
    }

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

    @JvmStatic
    fun <T : Any> strictStub(stubClass: Class<T>): T {
        return mockkClass(stubClass.kotlin, relaxed = false)
    }

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

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

Agora podemos criar strict stubs usando nosso novo método fábrica strictStub(). Vamos ver como configuramos o comportamento de métodos em um strict stub!


Configurando o Comportamento de um Strict Stub

Primeiro, criamos nosso strict stub antes que cada método de teste seja invocado, utilizando um método anotado com @BeforeEach:

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

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

class StrictStubTest {

    private lateinit var repository: TaskRepository

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

A Grande Diferença entre o Mockito e o MockK ao Configurar um Strict Stub

No Mockito, quando você cria um strict stub (cujo Answer padrão lança exceção), você não pode usar a sintaxe normal given(repository.findById(1L)).willReturn(...) nem when(repository.findById(1L)).thenReturn(...)!

Por quê? Porque, em Java, o compilador avalia o argumento repository.findById(1L) antes de chamar given(...) ou when(...). Como o repository é um strict stub ainda não configurado naquele instante, a própria linha de configuração lança RuntimeException("SUT invoked an unexpected method of a stub")!

Por isso, no Mockito, configurar um strict stub obrigava o desenvolvedor a escrever todas as configurações na ordem inversa:

  • BDDMockito: willReturn(Optional.of(expected)).given(repository).findById(1L);
  • Mockito: doReturn(Optional.of(expected)).when(repository).findById(1L);

No MockK, esse problema não existe! Como every { repository.findById(1L) } recebe um bloco lambda que é executado dentro do contexto de gravação do MockK (e não avaliado ansiosamente antes da função every), utilizamos exatamente a mesma sintaxe natural every { ... } returns ... tanto para stubs relaxados quanto para strict stubs!

Vamos demonstrar isso com os três cenários de configuração:

1. Retornando Valores e Objetos em um Strict Stub

Para configurar o método findById() da interface TaskRepository em nosso strictStub para retornar um Optional não vazio quando for invocado com 1L:

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

import com.testwithspring.starter.mockk.TestDoubles.dummy
import com.testwithspring.starter.mockk.TestDoubles.strictStub
import io.mockk.every
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.util.Optional

class StrictStubTest {

    private lateinit var repository: TaskRepository

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

    @Test
    fun `deve retornar objeto configurado no strict stub`() {
        val expected: Task = dummy()
        // No MockK, a sintaxe continua natural!
        // (No BDDMockito seria: willReturn(Optional.of(expected)).given(repository).findById(1L))
        every { repository.findById(1L) } returns Optional.of(expected)

        val actual = repository.findById(1L)

        assertThat(actual).containsSame(expected)
    }
}

E o que acontece se alguém chamar repository.findById(2L) ou repository.count() nesse teste? Como repository foi criado com strictStub(), o teste falha imediatamente informando que uma chamada não configurada ocorreu!

2. Lançando uma Exceção em um Strict Stub

Para configurar um método do nosso strictStub para lançar uma exceção:

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

import com.testwithspring.starter.mockk.TestDoubles.strictStub
import io.mockk.every
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows

class StrictStubTest {

    private lateinit var repository: TaskRepository

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

    @Test
    fun `deve lancar excecao configurada no strict stub`() {
        // (No BDDMockito seria: willThrow(new NotFoundException()).given(repository).findById(1L))
        every { repository.findById(1L) } throws NotFoundException()

        assertThrows<NotFoundException> {
            repository.findById(1L)
        }
    }
}

[!CAUTION] Lembrando novamente o aviso da aula anterior: o método findById() retorna Optional<Task> e não lança NotFoundException por contrato. Usamos este exemplo apenas para ilustrar a mecânica de throws em um strict stub; nunca quebre o contrato da sua API em testes reais!

3. Configurando um answers Dinâmico em um Strict Stub

Para configurar uma resposta dinâmica que inspeciona o argumento 1L em nosso strictStub, inspecionamos a suíte completa StrictStubTest:

StrictStubTest.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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
package com.testwithspring.starter.mockk

import com.testwithspring.starter.mockk.TestDoubles.dummy
import com.testwithspring.starter.mockk.TestDoubles.strictStub
import io.mockk.every
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import java.util.Optional

@DisplayName("04 - Comportamento Estrito vs Relaxado de Stubs no MockK")
class StrictStubTest {

    private lateinit var repository: TaskRepository

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

    @Test
    fun `deve retornar objeto configurado no strict stub`() {
        val expected: Task = dummy()
        every { repository.findById(1L) } returns Optional.of(expected)

        val actual = repository.findById(1L)

        assertThat(actual).containsSame(expected)
    }

    @Test
    fun `deve lancar excecao configurada no strict stub`() {
        every { repository.findById(1L) } throws NotFoundException()

        assertThrows<NotFoundException> {
            repository.findById(1L)
        }
    }

    @Test
    fun `deve retornar objeto usando answers no strict stub`() {
        val expected: Task = dummy()
        every { repository.findById(1L) } answers {
            val idParameter = firstArg<Long>()
            if (idParameter == 1L) {
                Optional.of(expected)
            } else {
                Optional.empty()
            }
        }

        val actual = repository.findById(1L)

        assertThat(actual).containsSame(expected)
    }
}

Executando Nossos Testes no Gradle

Ao executarmos a suíte StrictStubTest através do Gradle (./gradlew test --tests StrictStubTest -i), inspecionamos a evidência real de validação rigorosa dos Stubs Estritos:

Evidência Real de Execução: ./gradlew test --tests StrictStubTest -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.StrictStubTest > deve retornar objeto configurado no strict stub PASSED
com.testwithspring.starter.mockk.StrictStubTest > deve lancar excecao configurada no strict stub PASSED
com.testwithspring.starter.mockk.StrictStubTest > deve retornar objeto usando answers no strict stub PASSED

04 - Comportamento Estrito vs Relaxado de Stubs no MockK > 3 tests completed, 0 failed, 0 skipped
Finished generating test XML results (0.009 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.016 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

Devemos Usar Strict Stubs?

Os strict stubs possuem um grande benefício: eles garantem que o nosso teste falhará imediatamente se o Sistema Sob Teste invocar um método cujo comportamento não foi configurado. Embora o valor “vazio” retornado por um stub relaxado geralmente também acabe falhando o teste mais adiante, com um stub relaxado não temos 100% de garantia de que uma chamada inesperada será detectada na hora.

No Mockito clássico, havia um dilema difícil entre duas frases:

  1. “Quando X for invocado com a entrada Y, retorne Z” (given(X).willReturn(Z) — sintaxe dos stubs normais no Mockito).
  2. “Retorne Z quando X for invocado com a entrada Y” (willReturn(Z).given(X) — sintaxe invertida exigida pelos strict stubs no Mockito).

No Mockito, quem preferia a leitura natural da primeira frase acabava abrindo mão dos strict stubs para não ter que escrever willReturn(...).given(...) ao contrário.

Já no MockK, como every { X } returns Z mantém a ordem natural da primeira frase mesmo em stubs estritos (relaxed = false), você tem o melhor dos dois mundos:

  • Use strictStub() (mockk<T>(relaxed = false)) quando quiser máxima segurança e rigor sobre quais métodos de consulta o Sistema Sob Teste pode invocar, sabendo que qualquer chamada não configurada será barrada imediatamente pelo MockK sem sacrificar a legibilidade do every { ... } returns ....
  • Use stub() relaxado (mockk<T>(relaxed = true)) quando o colaborador possuir métodos auxiliares secundários que você não deseja configurar repetidamente em cada teste e você preferir que o teste foque apenas nas asserções de saída do Sistema Sob Teste.

Resumo

Nesta aula, aprendemos quatro pontos essenciais:

  • Um Strict Stub garante que o nosso caso de teste falhe imediatamente se o Sistema Sob Teste invocar um método cujo comportamento não foi explicitamente configurado.
  • No Mockito clássico, criávamos um strict stub substituindo o Answer padrão por um que lança exceção, o que nos forçava a usar a sintaxe invertida willReturn(...).given(...) / doReturn(...).when(...).
  • No MockK, o comportamento padrão de mockk<T>() (relaxed = false) já é estrito nativamente e preserva a sintaxe natural every { ... } returns ....
  • Podemos escolher entre strictStub() (relaxed = false) e stub() (relaxed = true) na nossa classe TestDoubles de acordo com o nível de rigor desejado para o teste.