Configurando Respostas Sequenciais, Computadas e Exceções

Depois de concluirmos esta aula, nós:

  • Saberemos como retornar o mesmo objeto ou valor em todas as invocações de um método stubbado (returns).
  • Saberemos como retornar objetos diferentes em invocações sucessivas (returnsMany).
  • Saberemos como determinar dinamicamente o objeto retornado inspecionando os parâmetros recebidos pelo método stubbado (answers { ... }).
  • Compreenderemos como modificar o próprio objeto recebido como parâmetro antes de retorná-lo (como atribuir um id ao salvar uma entidade).
  • Saberemos como fazer o método stubbado lançar uma exceção (throws).

1. Conhecendo as Mudanças no Sistema Sob Especificação

Nesta aula, continuaremos utilizando o stub de MessageRepository para especificar o comportamento da classe MessageService. Porém, fizemos duas adições importantes aos nossos componentes:

Primeiro, adicionamos o método save(message: Message): Message à interface MessageRepository:

1
2
3
4
5
6
7
8
package com.testwithspring.master.message

import java.util.Optional

interface MessageRepository {
    fun findById(id: Long?): Optional<Message>
    fun save(message: Message): Message
}

Segundo, adicionamos o método create(message: Message): Message à classe MessageService, que repassa a mensagem para repository.save(message) e retorna a mensagem salva:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package com.testwithspring.master.message

class MessageService(
    private val repository: MessageRepository
) {

    fun findById(id: Long?): Message {
        return repository.findById(id).orElseThrow {
            NotFoundException("No message found with id: $id")
        }
    }

    fun create(message: Message): Message {
        return repository.save(message)
    }
}

Vamos agora explorar seis exemplos práticos que demonstram todas as formas de configurar a resposta de um método stubbado com MockK e Kotest!


2. Configurando a Resposta de um Método Stubbado

2.1. Retornando o Mesmo Objeto (ou Valor) em Todas as Invocações (returns)

Quando queremos retornar exatamente o mesmo objeto ou valor toda vez que o método stubbado for chamado (equivalente ao operador >> do Spock), usamos a função infixa returns do MockK:

 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.master.message

import io.kotest.core.annotation.Tags
import io.kotest.core.spec.IsolationMode
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import java.util.Optional

@Tags("UnitTest")
class MessageServiceResponseSpec : BehaviorSpec({

    isolationMode = IsolationMode.InstancePerLeaf

    val repository = mockk<MessageRepository>()
    val service = MessageService(repository)

    Given("A message is found with the id 1") {
        val found = Message(id = 1L, messageText = "Hello World!")
        every { repository.findById(1L) } returns Optional.of(found)

        When("We find a message with the id 1") {
            val returned = service.findById(1L)

            Then("Should return the found message") {
                returned shouldBe found
            }
        }
    }
})

2.2. Retornando Objetos Diferentes em Invocações Sucessivas (returnsMany)

Quando queremos que o método stubbado retorne valores diferentes na primeira, segunda, terceira invocação e assim por diante (equivalente ao operador >>> do Spock), utilizamos a função infixa returnsMany passando uma lista de valores (ou encadeando .andThen(...)):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
    Given("Different messages are found on successive invocations") {
        val first = Message(id = 1L, messageText = "First message")
        val second = Message(id = 1L, messageText = "Second message")
        every { repository.findById(1L) } returnsMany listOf(
            Optional.of(first),
            Optional.of(second)
        )

        When("We find two objects by using the id 1") {
            val firstReturned = service.findById(1L)
            val secondReturned = service.findById(1L)

            Then("Should return different objects on successive invocations") {
                firstReturned shouldBe first
                secondReturned shouldBe second
            }
        }
    }

Como podemos ver, na primeira chamada findById(1L) retorna Optional.of(first), e na segunda chamada retorna Optional.of(second).

[!NOTE] Use respostas sequenciais com parcimônia! Na maioria dos casos, testes determinísticos devem fazer uma única chamada ao sistema sob teste. Entretanto, returnsMany é muito útil quando testamos algoritmos de retry ou paginação que invocam o mesmo colaborador várias vezes dentro de uma única operação.

2.3. Determinando o Objeto Retornado a Partir do Parâmetro do Método (answers)

Se quisermos decidir qual objeto retornar inspecionando o parâmetro passado ao método stubbado (equivalente ao >> { args -> ... } ou >> { Long id -> ... } do Spock), utilizamos a função answers { ... } do MockK.

Dentro da lambda de answers, o MockK fornece acesso aos argumentos da chamada de duas maneiras equivalentes às duas opções do Spock:

Opção 1 (Lista de argumentos args): podemos acessar a lista de todos os argumentos passados através da propriedade args (onde args[0] é o primeiro argumento):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
    Given("A message is found with the id 1 (using args list)") {
        val found = Message(id = 1L, messageText = "Hello World!")
        every { repository.findById(any()) } answers {
            val id = args[0] as Long?
            if (id == 1L) Optional.of(found) else Optional.empty()
        }

        When("We find a message with the id 1") {
            val returned = service.findById(1L)

            Then("Should return the found message") {
                returned shouldBe found
            }
        }
    }

Opção 2 (Helper tipado firstArg<Long>() — Recomendado!): em vez de fazer cast manual em args[0], o MockK oferece as funções tipadas firstArg<T>(), secondArg<T>(), thirdArg<T>() e lastArg<T>():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
    Given("A message is found with the id 1 (using typed firstArg)") {
        val found = Message(id = 1L, messageText = "Hello World!")
        every { repository.findById(any()) } answers {
            val id = firstArg<Long?>()
            if (id == 1L) Optional.of(found) else Optional.empty()
        }

        When("We find a message with the id 1") {
            val returned = service.findById(1L)

            Then("Should return the found message") {
                returned shouldBe found
            }
        }
    }

[!TIP] A segunda opção (firstArg<Long?>()) é muito mais limpa, segura em relação a tipos e idiomática em Kotlin do que indexar args[0].

2.4. Construindo o Objeto Retornado Dentro de uma Lambda (answers)

Também podemos usar answers { ... } quando queremos instanciar e configurar o objeto retornado sob demanda a cada invocação do método stubbado:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
    Given("A message is constructed dynamically with the id 1") {
        every { repository.findById(1L) } answers {
            Optional.of(Message(id = 1L, messageText = "Hello World!"))
        }

        When("We find a message with the id 1") {
            val returned = service.findById(1L)

            Then("Should return the found message with correct id") {
                returned.id shouldBe 1L
            }

            And("Should return the found message with correct message text") {
                returned.messageText shouldBe "Hello World!"
            }
        }
    }

2.5. Modificando o Parâmetro do Método Antes de Retorná-lo

Um caso de uso extremamente comum ao testar serviços que salvam entidades no banco de dados é simular a geração da chave primária (id): o método repository.save(message) recebe um objeto Message com id = null, atribui id = 1L nesse objeto e o retorna.

Com answers e firstArg<Message>(), podemos fazer exatamente isso:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
    Given("We want to create a new message") {
        val message = Message(messageText = "Hello World!")

        And("An id is assigned to the saved message before it is returned") {
            every { repository.save(message) } answers {
                val saved = firstArg<Message>()
                saved.id = 1L
                saved
            }

            When("We create a new message") {
                val returned = service.create(message)

                Then("Should return the saved message with correct id") {
                    returned.id shouldBe 1L
                }

                And("Should return the saved message with correct message text") {
                    returned.messageText shouldBe "Hello World!"
                }
            }
        }
    }

2.6. Lançando uma Exceção (throws)

Se quisermos garantir que o método stubbado lance uma exceção quando for invocado (equivalente ao >> { throw new ... } do Spock), utilizamos a função infixa throws do MockK:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
    Given("Repository throws an exception when queried with id 1") {
        every { repository.findById(1L) } throws NotFoundException("not found")

        When("We find a message by using the id 1") {
            Then("Should propagate the thrown NotFoundException") {
                shouldThrow<NotFoundException> {
                    service.findById(1L)
                }
            }
        }
    }

[!WARNING] Cuidado para não violar o contrato da API ao configurar um Stub!

Pelo contrato real da nossa interface MessageRepository, o método findById() não lança NotFoundException quando nenhuma mensagem é encontrada — ele retorna Optional.empty(), e quem lança NotFoundException é o MessageService!

Usamos o exemplo acima exclusivamente para demonstrar a sintaxe de throws. Em seus testes reais, nunca configure um stub para retornar valores ou lançar exceções que contradigam o contrato real do colaborador, pois isso gerará testes falsos-positivos que passam no build mas falham em produção!


Resumo

Nesta aula, aprendemos quatro técnicas essenciais para configurar o retorno de stubs com MockK no Kotest:

  • Retornamos um valor fixo em todas as invocações usando every { ... } returns valor (e valores sequenciais usando returnsMany).
  • Determinamos o retorno dinamicamente com base nos argumentos recebidos usando answers { firstArg<T>() ... }.
  • Podemos modificar o argumento recebido e devolvê-lo dentro de answers { ... }.
  • Fazemos o método stubbado lançar uma exceção usando every { ... } throws MinhaException(...).