Idiomatismos Avançados em Kotlin com MockK

Ao concluirmos esta aula, nós:

  • Saberemos como configurar a resposta dinâmica de um método stubado utilizando expressões lambda concisas (answers { ... }).
  • Seremos capazes de criar argument matchers customizados diretamente com expressões lambda (match { ... }).
  • Saberemos como escrever asserções para o parâmetro real de um método invocado utilizando uma expressão lambda (withArg { ... }) sem precisar instanciar um CapturingSlot manualmente.

[!NOTE] Esta aula assume que:


Por Que Devemos Nos Importar com Código Boilerplate nos Testes?

Ferramentas de mocking tradicionais (como o Mockito clássico no Java pré-Java 8, que exigia classes anônimas new Answer<T>(), matchers customizados verbosos e ArgumentCaptor de três etapas) muitas vezes forçavam o desenvolvedor a escrever código repetitivo (boilerplate) que desviava a atenção da essência do caso de teste para detalhes burocráticos da ferramenta.

No Java 8+, surgiram lambdas para willAnswer(invocation -> ...) e bibliotecas auxiliares como mockito-java8 (argLambda e assertArg). Já no ecossistema Kotlin + MockK, esses três truques baseados em expressões lambda já vêm embutidos nativamente na biblioteca, sem precisar de nenhuma dependência extra!

Vamos dominar esses três idiomatismos que eliminam o código boilerplate dos nossos testes unitários.


1. Configurando Respostas Dinâmicas com Expressões Lambda (answers)

No Mockito antigo (Java 7), quando queríamos configurar uma resposta dinâmica que inspecionava os argumentos recebidos, éramos obrigados a instanciar uma classe interna anônima implementando a interface Answer<T>:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// Legado Java / Mockito sem lambda: excesso de ruído visual!
given(repository.findById(1L)).willAnswer(new Answer<Optional<Task>>() {
    @Override
    public Optional<Task> answer(InvocationOnMock invocation) throws Throwable {
        Long idParameter = (Long) invocation.getArguments()[0];
        if (idParameter.equals(1L)) {
            return Optional.of(expected);
        } else {
            return Optional.empty();
        }
    }
});

O problema desse código antigo é que tínhamos que escrever várias linhas de cerimônia que não tinham nada a ver com a regra do nosso caso de teste. Com expressões lambda (tanto em willAnswer(invocation -> ...) quanto no bloco nativo answers { ... } do MockK em Kotlin), eliminamos toda a classe anônima e escrevemos exclusivamente a lógica relevante para o teste:

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

import com.testwithspring.starter.mockk.TestDoubles.dummy
import com.testwithspring.starter.mockk.TestDoubles.stub
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 MockKIdiomsStubTest {

    private lateinit var repository: TaskRepository

    @BeforeEach
    fun createStub() {
        repository = stub()
    }

    @Test
    fun `deve retornar objeto usando answers com expressao lambda limpa`() {
        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)
    }
}

O benefício dessa abordagem é imediato: escrevemos menos código e o código que escrevemos contém apenas a lógica relevante para o nosso caso de teste. Mas podemos ir ainda mais longe com os próximos dois truques!


2. Usando um Lambda Matcher (match { ... })

Suponha que queiramos configurar o parâmetro esperado de um método stubado (ou verificado) usando uma condição customizada que não é suportada pelos matchers básicos (eq(), any(), ofType()).

No Java clássico com Mockito, ou você escrevia uma classe inteira de ArgumentMatcher customizado, ou adicionava a biblioteca externa info.solidsoft.mockito:mockito-java8 para usar argLambda(task -> task.getId() == null) (ou argThat(task -> ...) nas versões mais recentes do Mockito).

No MockK, essa funcionalidade já faz parte do núcleo da biblioteca através da função match<T> { ... }!

Por exemplo, se quisermos garantir que o método save() da interface TaskRepository retorne o objeto Task salvo somente se o objeto Task passado como parâmetro não possuir id (task.id == null), configuramos o parâmetro esperado seguindo estes dois passos:

  1. Invocamos a função match<Task> { ... } do pacote io.mockk.match como argumento de repository.save(...).
  2. Especificamos a condição booleana dentro da expressão lambda: { task -> task.id == null } (ou simplesmente { it.id == null }).

Depois de criarmos nosso argument matcher com uma expressão lambda, o código-fonte do nosso teste unitário 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
35
36
37
38
39
40
package com.testwithspring.starter.mockk

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

class MockKIdiomsStubTest {

    companion object {
        private const val CREATOR_ID = 1L
        private const val TASK_DESCRIPTION = "description"
        private const val TASK_TITLE = "title"
    }

    private lateinit var repository: TaskRepository

    @BeforeEach
    fun createStub() {
        repository = stub()
    }

    @Test
    fun `deve retornar objeto quando parametro corresponder ao lambda matcher`() {
        val saved: Task = dummy()
        // Equivalente ao argLambda(task -> task.getId() == null) / argThat(...)
        every { repository.save(match { task -> task.id == null }) } returns saved

        val created = TaskBuilder()
            .withCreator(CREATOR_ID)
            .withTitle(TASK_TITLE)
            .withDescription(TASK_DESCRIPTION)
            .build()
        val actual = repository.save(created)

        assertThat(actual).isSameAs(saved)
    }
}

Essa técnica com match { ... } nos permite expressar condições de correspondência arbitrárias e ricas sem escrever uma única linha de código boilerplate!


3. Capturando e Validando Argumentos com Lambda (withArg { ... })

Como vimos na aula sobre Mocks, quando queremos capturar o parâmetro real passado para um método e escrever asserções do AssertJ sobre ele usando um CapturingSlot (slot<T>() / ArgumentCaptor<T>), precisamos escrever três etapas separadas:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Abordagem tradicional com slot<Long>() (ou ArgumentCaptor no Mockito)
@Test
fun `verificar chamada e capturar parametro com slot tradicional`() {
    repository.findById(1L)

    val argument = slot<Long>()
    verify(exactly = 1) { repository.findById(capture(argument)) }

    val actualId = argument.captured
    assertThat(actualId).isEqualByComparingTo(1L)
}

Como podemos notar, esse teste possui algumas linhas de cerimônia intermediária (val argument = slot<Long>(), capture(argument), val actualId = argument.captured).

Para eliminar completamente esse código boilerplate, o MockK oferece nativamente o matcher withArg<T> { ... } (o equivalente exato ao AssertionMatcher.assertArg(...) do mockito-java8 / Mockito.assertArg)!

Para capturar o parâmetro real e escrever asserções do AssertJ sobre ele em um único passo:

  1. Invocamos a função withArg<Long> { ... } dentro da chamada verificada em verify.
  2. Dentro da expressão lambda de withArg, escrevemos diretamente nossas asserções do AssertJ garantindo que o objeto capturado contém as informações corretas!

Veja como o nosso teste unitário que verifica a chamada findById(1L) fica muito mais limpo usando withArg:

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

import io.mockk.mockk
import io.mockk.verify
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test

class MockKWithArgTest {

    private lateinit var repository: TaskRepository

    @BeforeEach
    fun createMock() {
        repository = mockk(relaxed = true)
    }

    @Test
    fun `deve verificar chamada e validar parametro usando withArg`() {
        repository.findById(1L)

        verify(exactly = 1) {
            repository.findById(withArg { argument ->
                assertThat(argument).isEqualByComparingTo(1L)
            })
        }
    }
}

Como podemos ver, com withArg { ... } capturamos o parâmetro real passado ao método e executamos qualquer asserção do AssertJ (incluindo soft assertions ou asserções customizadas de domínio!) sem precisar declarar variáveis de slot ou ArgumentCaptor. O melhor dessa solução é que nosso teste unitário fica muito mais enxuto e destaca imediatamente a essência do caso de teste!


Resumo

Nesta aula, aprendemos três idiomatismos poderosos em Kotlin com o MockK:

  • Podemos configurar respostas dinâmicas que inspecionam argumentos usando uma expressão lambda limpa com answers { firstArg<T>() ... } (substituindo classes anônimas Answer<T>).
  • Podemos criar argument matchers customizados inline utilizando expressões lambda com match { ... }.
  • Podemos capturar o parâmetro real passado para um método e executar asserções do AssertJ diretamente dentro de verify { mock.method(withArg { ... }) }, eliminando o código boilerplate de slot<T>() / ArgumentCaptor.