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:

 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:

 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
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 usando answers no strict stub`() {
        val expected: Task = dummy()
        // (No BDDMockito seria: willAnswer(...).given(repository).findById(1L))
        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)
    }
}

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.