Testes Unitários de API REST com Kotlin: Configuração

Ao concluir esta aula, seremos capazes de:

  • Compreender como podemos remover código duplicado da nossa suíte de testes.
  • Configurar o sistema sob teste (System Under Test — SUT) utilizando a configuração standalone do Spring MVC Test.

Nota — Este webinar assume que:

Obtenha o código-fonte de referência no GitHub

Removendo Código Duplicado da Nossa Suíte de Testes

Não queremos adicionar código duplicado à nossa suíte de testes porque isso torna nossos testes difíceis de escrever e de manter. Durante esta aula, aprenderemos duas técnicas que nos ajudam a remover código duplicado da suíte de testes quando escrevemos testes unitários para controllers Spring MVC em Kotlin 2.4.21 e Spring Boot 4.1.2.

A seguir, veremos como criar os componentes que sobrescrevem a configuração padrão fornecida pelo framework Spring MVC Test.

Criando os Componentes Adicionais

Como vimos anteriormente, se criarmos os componentes necessários diretamente dentro das nossas classes de teste, introduziremos código duplicado na suíte de testes, pois é muito provável que múltiplas classes de teste utilizem exatamente a mesma configuração de infraestrutura web. Por essa razão, devemos criar uma classe Object Mother responsável por instanciar e configurar esses componentes. Podemos construir nossa classe Object Mother seguindo estes passos:

Primeiro, devemos criar uma nova classe e garantir que ela não possa ser instanciada externamente declarando seu construtor primário como private. Após criarmos essa classe, seu código-fonte inicial é o seguinte:

1
2
3
class WebTestConfig private constructor() {

}

Segundo, devemos escrever as funções fábrica estáticas (dentro de um companion object) que criam e configuram os componentes necessários. Precisamos adicionar três funções fábrica à nossa classe Object Mother:

  1. A função fixedLocaleResolver() retorna um novo objeto FixedLocaleResolver que sempre resolve o locale para Locale.ENGLISH. É uma excelente prática fixar o locale porque isso garante que o ambiente do sistema operacional ou do servidor de CI onde nossos testes são executados não cause falsos positivos (isto é, falhas de teste provocadas por diferenças de idioma/formatação local).
  2. A função objectMapper() cria e configura um novo objeto ObjectMapper. Isolamos essa lógica em uma função dedicada porque também a reutilizaremos quando precisarmos serializar objetos para documentos JSON enviados no corpo das requisições HTTP ao sistema sob teste.
  3. A função objectMapperHttpMessageConverter() cria um novo objeto MappingJackson2HttpMessageConverter capaz de ler e escrever documentos JSON utilizando o ObjectMapper configurado com Jackson (incluindo o suporte a JavaTimeModule, Jdk8Module e kotlinModule() para data classes do Kotlin).

Após adicionarmos essas funções fábrica ao companion object da nossa classe Object Mother, o código-fonte completo de WebTestConfig 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
41
42
43
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.kotlinModule
import java.util.Locale
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter
import org.springframework.web.servlet.LocaleResolver
import org.springframework.web.servlet.i18n.FixedLocaleResolver

class WebTestConfig private constructor() {

    companion object {
        private val LOCALE = Locale.ENGLISH

        fun fixedLocaleResolver(): LocaleResolver {
            return FixedLocaleResolver(LOCALE)
        }

        fun objectMapperHttpMessageConverter(): MappingJackson2HttpMessageConverter {
            val converter = MappingJackson2HttpMessageConverter()
            converter.objectMapper = objectMapper()
            return converter
        }

        fun objectMapper(): ObjectMapper {
            return Jackson2ObjectMapperBuilder()
                .featuresToDisable(
                    SerializationFeature.WRITE_DATES_AS_TIMESTAMPS
                )
                .featuresToEnable(
                    SerializationFeature.WRITE_DATES_WITH_ZONE_ID
                )
                .modulesToInstall(
                    Jdk8Module(),
                    JavaTimeModule(),
                    kotlinModule()
                )
                .build()
        }
    }
}

Vamos avançar e descobrir como podemos criar uma classe Request Builder encarregada de construir e enviar requisições HTTP para o sistema sob teste.

Criando uma Classe Request Builder

Quando escrevemos testes para uma aplicação web real ou para uma API REST, rapidamente percebemos que cada método de teste constrói uma nova requisição HTTP e a envia ao sistema sob teste. Essa repetição direta dentro dos testes é prejudicial porque código duplicado de montagem de requisições (URIs, cabeçalhos, Content-Type, serialização de payload) torna a suíte difícil de escrever, ler e manter.

Podemos resolver esse problema utilizando classes Request Builder. Uma classe Request Builder é uma classe auxiliar de teste que cumpre duas condições essenciais:

  • Contém funções que criam e enviam requisições HTTP ao sistema sob teste utilizando um objeto MockMvc (seja via MockMvcRequestBuilders tradicional ou via MockMvc Kotlin DSL com mockMvc.get, mockMvc.post, mockMvc.put e mockMvc.delete).
  • Cada função retorna um objeto ResultActions (ou ResultActionsDsl na DSL idiomática do Kotlin), permitindo que o método de teste escreva asserções fluentes sobre a resposta HTTP retornada.

Quando queremos escrever uma nova classe Request Builder em Kotlin, devemos seguir estes passos:

  1. Criar uma nova classe (TaskCrudRequestBuilder).
  2. Adicionar um construtor primário à classe criada.
  3. Garantir que o construtor primário declare uma propriedade private val mockMvc: MockMvc (sem expor getter ou setter público). Nossa classe Request Builder utilizará essa propriedade interna sempre que precisar criar e enviar requisições HTTP ao sistema sob teste.

Após criarmos a estrutura inicial da nossa classe Request Builder, seu código-fonte fica da seguinte forma:

1
2
3
4
5
import org.springframework.test.web.servlet.MockMvc

class TaskCrudRequestBuilder(private val mockMvc: MockMvc) {
    // Adicionar funções que criam e enviam requisições HTTP
}

Nota:

Neste momento, essa classe Request Builder ainda não faz nada útil porque ainda não escrevemos as funções que criam e disparam as requisições HTTP para o sistema sob teste. Adicionaremos essas funções passo a passo nas próximas aulas, à medida que implementarmos os testes unitários para cada operação da nossa API REST.

Leitura Adicional:

Agora que criamos os componentes que nos ajudam a eliminar código duplicado ao escrever testes unitários para uma API REST, vamos aprender a configurar o sistema sob teste utilizando esses componentes.

Configurando o Sistema Sob Teste

Antes de configurarmos o sistema sob teste (System Under Test — SUT), precisamos decidir qual será o tamanho da unidade testada (unit size). Quando desenvolvedores escrevem testes unitários, é muito comum adotarem a menor unidade possível: uma única classe isolada, substituindo todas as dependências diretas da classe testada por dublês de teste (test doubles).

A figura a seguir ilustra o tamanho da unidade testada quando utilizamos essa abordagem tradicional focada apenas na classe do controller:

Tamanho da unidade no teste unitário de classe

flowchart LR Test["TaskCrudControllerTest"] -->|"HTTP Request via MockMvc"| Controller["TaskCrudController (SUT)"] Controller -->|"Delega chamada"| ServiceMock["TaskCrudService (MockK)"]

O problema dessa abordagem é que podemos acabar escrevendo testes unitários superficiais (shallow unit tests), especialmente quando a classe testada não possui lógica de negócios própria. Por exemplo, se estivermos escrevendo testes unitários para um controller Spring MVC e substituirmos seu serviço (TaskCrudService) por um mock, não conseguiremos testar nenhuma regra de negócio. Só conseguiremos garantir que:

  • Nossa requisição HTTP é roteada e processada pela função correta do controller.
  • Nossas regras de validação de entrada estão funcionando conforme o esperado.
  • Os dados corretos são repassados para a função esperada do serviço.
  • O sistema sob teste retorna a resposta HTTP esperada a partir do valor simulado pelo serviço.

Desta vez, faremos algo diferente e muito mais expressivo: vamos aumentar o tamanho da unidade testada para abranger tanto o controller quanto a camada de serviço (TaskCrudController + TaskCrudService), substituindo apenas as dependências externas do nosso componente de serviço por dublês de teste criados com MockK 1.14. O objetivo desse experimento é demonstrar como essa abordagem nos ajuda a escrever testes unitários mais significativos e resilientes a refatorações internas para uma API REST com Spring.

A figura a seguir ilustra o tamanho da unidade testada quando adotamos essa abordagem de teste unitário de funcionalidade (feature unit test):

Tamanho da unidade no teste unitário de funcionalidade

flowchart LR Test["TaskCrudControllerTest"] -->|"HTTP Request via MockMvc"| Controller["TaskCrudController (SUT)"] Controller --> Service["TaskCrudService (SUT)"] Service --> PersonFinderMock["PersonFinder (MockK)"] Service --> TaskRepoMock["TaskRepository (MockK)"]

Podemos agora configurar o sistema sob teste seguindo quatro passos:

Primeiro, devemos criar uma nova classe de teste (TaskCrudControllerTest) e garantir que suas funções de teste sejam executadas quando rodarmos nossa suíte de testes unitários com JUnit Jupiter 6.0.3 (anotando a classe com @Tag("unitTest") e @DisplayName). Após criarmos nossa classe de teste, seu código-fonte inicial é o seguinte:

1
2
3
4
5
6
7
8
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Tag

@Tag("unitTest")
@DisplayName("Perform CRUD operations for tasks")
class TaskCrudControllerTest {

}

Segundo, devemos adicionar três propriedades private lateinit var à nossa classe de teste:

  • A classe PersonFinder busca dados de pessoas no banco de dados.
  • A classe TaskRepository fornece operações CRUD para tarefas (tasks).
  • A classe TaskCrudRequestBuilder cria requisições HTTP e envia essas requisições ao sistema sob teste.

Após adicionarmos essas propriedades à nossa classe de teste, seu código-fonte fica assim:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Tag

@Tag("unitTest")
@DisplayName("Perform CRUD operations for tasks")
class TaskCrudControllerTest {

    private lateinit var personFinder: PersonFinder
    private lateinit var repository: TaskRepository
    private lateinit var requestBuilder: TaskCrudRequestBuilder
}

Terceiro, devemos adicionar uma função de configuração (setup) à nossa classe de teste e anotá-la com @BeforeEach para garantir que ela seja invocada antes da execução de cada função de teste. Após adicionarmos essa função à nossa classe de teste, seu código-fonte fica da seguinte forma:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Tag

@Tag("unitTest")
@DisplayName("Perform CRUD operations for tasks")
class TaskCrudControllerTest {

    private lateinit var personFinder: PersonFinder
    private lateinit var repository: TaskRepository
    private lateinit var requestBuilder: TaskCrudRequestBuilder

    @BeforeEach
    fun configureSystemUnderTest() {

    }
}

Quarto, devemos implementar nossa função configureSystemUnderTest() seguindo estes passos:

  1. Criar mocks para PersonFinder e TaskRepository utilizando a função mockk() do MockK 1.14 e armazenar os objetos mock criados nas propriedades personFinder e repository.
  2. Criar uma nova instância real de TaskCrudService (injetando os mocks personFinder e repository) e armazená-la em uma variável local service.
  3. Criar um novo objeto MockMvc utilizando a configuração standalone (MockMvcBuilders.standaloneSetup(...)) com uma instância real de TaskCrudController(service), registrando o @ControllerAdvice (TaskErrorHandler()), o LocaleResolver fixo (WebTestConfig.fixedLocaleResolver()) e o conversor HTTP Jackson (WebTestConfig.objectMapperHttpMessageConverter()), armazenando o resultado na variável local mockMvc.
  4. Criar um novo objeto TaskCrudRequestBuilder(mockMvc) e armazená-lo na propriedade requestBuilder.

Após implementarmos nossa função de configuração, o código-fonte completo da nossa classe de teste 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
import io.mockk.mockk
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Tag
import org.springframework.test.web.servlet.setup.MockMvcBuilders

@Tag("unitTest")
@DisplayName("Perform CRUD operations for tasks")
class TaskCrudControllerTest {

    private lateinit var personFinder: PersonFinder
    private lateinit var repository: TaskRepository
    private lateinit var requestBuilder: TaskCrudRequestBuilder

    @BeforeEach
    fun configureSystemUnderTest() {
        personFinder = mockk()
        repository = mockk()
        val service = TaskCrudService(personFinder, repository)

        val mockMvc = MockMvcBuilders
            .standaloneSetup(TaskCrudController(service))
            .setControllerAdvice(TaskErrorHandler())
            .setLocaleResolver(WebTestConfig.fixedLocaleResolver())
            .setMessageConverters(
                WebTestConfig.objectMapperHttpMessageConverter()
            )
            .build()
        requestBuilder = TaskCrudRequestBuilder(mockMvc)
    }
}

Agora sabemos como configurar o sistema sob teste utilizando a configuração standalone do Spring MVC Test em conjunto com Kotlin e MockK. Vamos resumir o que aprendemos nesta aula.

Resumo

Esta aula nos ensinou três pontos fundamentais:

  • Podemos configurar o sistema sob teste sem escrever código duplicado utilizando uma classe Object Mother (WebTestConfig) que cria e configura todos os componentes de infraestrutura adicionados ao sistema sob teste.
  • Podemos enviar requisições HTTP ao sistema sob teste sem espalhar código duplicado pelos testes utilizando uma classe Request Builder (TaskCrudRequestBuilder).
  • Quando queremos configurar o sistema sob teste utilizando a modalidade standalone do Spring MVC Test, invocamos o método standaloneSetup() da classe MockMvcBuilders passando o controller real e encadeando a configuração de ControllerAdvice, LocaleResolver e HttpMessageConverter.

Obtenha o código-fonte de referência no GitHub