Simulando APIs REST Externas: Parte 1 (MockK, Mockito e Stubs Clássicos)

Depois de concluirmos esta aula bônus, nós:

  • Saberemos como criar mocks e stubs de componentes que consomem APIs REST externas.
  • Saberemos registrar e utilizar esses test doubles dentro do ApplicationContext dos nossos testes de integração.
  • Entenderemos os prós e contras de utilizar uma biblioteca de mocking (MockK / Mockito) para substituir o serviço cliente.
  • Identificaremos os prós e contras de implementar Stubs Clássicos (classes concretas de teste).
  • Saberemos escolher a melhor ferramenta para cada cenário.

1. Introdução ao Sistema Sob Teste (SUT)

Nosso sistema sob teste consiste em quatro componentes que trabalham juntos para consultar a alíquota de IVA (VAT percentage) de um país:

1. O Controller VatPercentageController

A classe VatPercentageController fornece um endpoint REST simples utilizado para consultar a porcentagem de imposto (VAT) aplicada em um país específico.

Quando queremos obter a alíquota de um país, enviamos uma requisição GET para o caminho /api/vat-percentage informando o código do país no parâmetro de query countryCode (ex: /api/vat-percentage?countryCode=FI). O controller delega a busca ao método findByCountryCode() da classe VatPercentageService e retorna o objeto VatPercentage encontrado:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package com.testwithspring.intermediate.vat

import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController

@RestController
class VatPercentageController(
    private val service: VatPercentageService
) {
    @GetMapping("/api/vat-percentage")
    fun findByCountryCode(@RequestParam("countryCode") countryCode: String): VatPercentage {
        return service.findByCountryCode(countryCode)
    }
}

2. O Cliente HTTP VatPercentageService

Como as alíquotas fiscais de diferentes países podem mudar periodicamente, decidimos não manter uma tabela própria de alíquotas e, em vez disso, consultar uma API REST externa. A classe VatPercentageService busca a alíquota em uma API externa utilizando RestTemplate (ou RestClient no Spring Boot 4):

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

import org.springframework.web.client.RestTemplate

open class VatPercentageService(
    private val restTemplate: RestTemplate?
) {
    open fun findByCountryCode(countryCode: String): VatPercentage {
        val client = restTemplate
            ?: throw IllegalStateException("RestTemplate não foi configurado")

        return client.getForObject(
            "http://localhost:8080/api/external/vat-percentage?countryCode=$countryCode",
            VatPercentage::class.java
        ) ?: throw IllegalStateException("Resposta vazia da API externa")
    }
}

Nota: Este exemplo didático utiliza uma URL fixa (http://localhost:8080/api/external/vat-percentage) porque ela atende aos requisitos desta aula e da próxima aula com WireMock. Em aplicações reais de produção, sempre injete a URL base via @ConfigurationProperties ou variáveis de ambiente!

3. O DTO VatPercentage

A classe VatPercentage é um simples Data Transfer Object (DTO) que armazena o código do país (countryCode) e sua respectiva alíquota (vatPercentage):

1
2
3
4
5
6
package com.testwithspring.intermediate.vat

data class VatPercentage(
    var countryCode: String = "",
    var vatPercentage: Int = 0
)

4. Isolando o Bean Real com @Profile na Configuração da Aplicação

Não queremos invocar uma API externa real na internet quando executamos nossos testes de integração, pois isso criaria um acoplamento indesejado e perigoso:

  • Nossos testes de integração falhariam sempre que a API externa estivesse fora do ar ou instável.
  • A latência de rede deixaria nossa suíte de testes de integração muito mais lenta.

Por isso, garantimos que o bean “real” de VatPercentageService seja registrado no container do Spring apenas quando a aplicação real estiver rodando (ou seja, quando o perfil Profiles.APPLICATION estiver ativo), utilizando a anotação @Profile:

Dica de Arquitetura: Embora possamos colocar @Profile diretamente em uma classe anotada com @Service/@Component, é muito mais fácil localizar todos os beans dependentes de perfil quando os declaramos explicitamente via métodos @Bean em nossa classe de configuração!

Veja como fica nossa classe principal ExampleApplication:

 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.intermediate

import com.testwithspring.intermediate.vat.VatPercentageService
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Profile
import org.springframework.web.client.RestTemplate

@SpringBootApplication
class ExampleApplication {

    @Bean
    fun restTemplate(): RestTemplate {
        return RestTemplate()
    }

    @Bean
    @Profile(Profiles.APPLICATION)
    fun vatPercentageService(restTemplate: RestTemplate): VatPercentageService {
        return VatPercentageService(restTemplate)
    }
}

fun main(args: Array<String>) {
    runApplication<ExampleApplication>(*args)
}

A seguir, veremos três maneiras diferentes de fornecer um bean VatPercentageService para os nossos testes de integração (quando o perfil Profiles.INTEGRATION_TEST estiver ativo) sem depender da API externa. Começaremos criando um Stub com MockK / Mockito!


2. Criando um Stub do Nosso Serviço com MockK / Mockito

Se quisermos criar um stub que seja gerenciado pelo container do Spring, criamos uma classe de configuração de contexto (IntegrationTestStubContext) que importa a configuração de ExampleApplication e registra um bean VatPercentageService para o perfil Profiles.INTEGRATION_TEST:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
package com.testwithspring.intermediate.vat

import com.testwithspring.intermediate.ExampleApplication
import com.testwithspring.intermediate.Profiles
import io.mockk.mockk
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Import
import org.springframework.context.annotation.Profile

@Configuration
@Import(ExampleApplication::class)
class IntegrationTestStubContext {

    @Bean
    @Profile(Profiles.INTEGRATION_TEST)
    fun vatPercentageService(): VatPercentageService {
        // Cria um stub gerenciado pelo Spring (equivalente a TestDoubles.stub(...) ou Mockito.mock(...))
        return mockk<VatPercentageService>()
    }
}

Por que não configuramos o retorno do stub dentro de IntegrationTestStubContext? Porque a mesma classe de configuração de contexto costuma ser compartilhada por diferentes testes de integração que precisam que o método do stub retorne valores diferentes (ou lance exceções diferentes) em cada cenário!

Depois de criarmos o bean stub, podemos escrever nossos testes de integração seguindo estes 5 passos:

Passo 1: Criar a Classe de Teste e Configurar o MockMvc

Criamos a classe FindVatPercentageStubTest, configuramos @SpringBootTest(classes = [IntegrationTestStubContext::class]), @AutoConfigureMockMvc e ativamos o perfil @ActiveProfiles(Profiles.INTEGRATION_TEST).

Passo 2: Declarar as Constantes e Injetar o Stub VatPercentageService

Adicionamos as constantes COUNTRY_CODE = "FI" e VAT_PERCENTAGE = 24, e injetamos tanto o MockMvc quanto o bean VatPercentageService via @Autowired.

Passo 3: Implementar o Método @BeforeEach (Resetar e Configurar o Stub)

No método @BeforeEach, executamos duas etapas fundamentais:

  1. Resetamos o estado do nosso stub (clearMocks(service) no MockK ou Mockito.reset(service) no Mockito). Precisamos fazer isso porque o nosso stub é um bean singleton gerenciado pelo container do Spring e reutilizado entre todos os testes!
  2. Configuramos o objeto VatPercentage retornado quando service.findByCountryCode(COUNTRY_CODE) for chamado (every { service.findByCountryCode(COUNTRY_CODE) } returns found ou given(service.findByCountryCode(COUNTRY_CODE)).willReturn(found)).

Passo 4: Criar o Método Auxiliar de Requisição (findByCountryCode)

Criamos um método privado findByCountryCode(countryCode: String): ResultActions que envia a requisição GET /api/vat-percentage?countryCode=... utilizando o MockMvc.

Passo 5: Escrever os Três Testes de Integração

Escrevemos três testes de integração verificando:

  1. O código de status HTTP 200 OK.
  2. O Content-Type JSON (application/json).
  3. O corpo JSON retornado ($.countryCode igual a "FI" e $.vatPercentage igual a 24).

Veja o código completo da classe FindVatPercentageStubTest:

 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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
package com.testwithspring.intermediate.vat

import com.testwithspring.intermediate.Profiles
import io.mockk.clearMocks
import io.mockk.every
import org.hamcrest.Matchers.`is`
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.MediaType
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.ResultActions
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status

@SpringBootTest(classes = [IntegrationTestStubContext::class])
@AutoConfigureMockMvc
@Tag("integrationTest")
@ActiveProfiles(Profiles.INTEGRATION_TEST)
class FindVatPercentageStubTest {

    companion object {
        private const val COUNTRY_CODE = "FI"
        private const val VAT_PERCENTAGE = 24
    }

    @Autowired
    private lateinit var mockMvc: MockMvc

    @Autowired
    private lateinit var service: VatPercentageService

    @BeforeEach
    fun configureTestCases() {
        resetStub()
        returnVatPercentage()
    }

    private fun resetStub() {
        clearMocks(service) // Equivalente ao Mockito.reset(service)
    }

    private fun returnVatPercentage() {
        val found = VatPercentage(
            countryCode = COUNTRY_CODE,
            vatPercentage = VAT_PERCENTAGE
        )

        every { service.findByCountryCode(COUNTRY_CODE) } returns found
    }

    @Test
    fun `deve retornar status HTTP 200 OK`() {
        findByCountryCode(COUNTRY_CODE)
            .andExpect(status().isOk)
    }

    @Test
    fun `deve retornar a porcentagem de VAT como JSON`() {
        findByCountryCode(COUNTRY_CODE)
            .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
    }

    @Test
    fun `deve retornar a porcentagem de VAT correta`() {
        findByCountryCode(COUNTRY_CODE)
            .andExpect(jsonPath("$.countryCode", `is`(COUNTRY_CODE)))
            .andExpect(jsonPath("$.vatPercentage", `is`(VAT_PERCENTAGE)))
    }

    private fun findByCountryCode(countryCode: String): ResultActions {
        return mockMvc.perform(
            get("/api/vat-percentage")
                .param("countryCode", countryCode)
        )
    }
}

Vamos descobrir agora como usar um Mock quando queremos verificar a interação entre o controller e o serviço.


3. Criando um Mock do Nosso Serviço com MockK / Mockito

Se quisermos verificar que o sistema sob teste (VatPercentageController) realmente invoca um método do nosso test double com o parâmetro esperado, devemos utilizar um Mock em vez de um Stub.

Para deixar nossa intenção cristalina para quem lê o código de teste, criamos a classe de configuração IntegrationTestMockContext:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package com.testwithspring.intermediate.vat

import com.testwithspring.intermediate.ExampleApplication
import com.testwithspring.intermediate.Profiles
import io.mockk.mockk
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Import
import org.springframework.context.annotation.Profile

@Configuration
@Import(ExampleApplication::class)
class IntegrationTestMockContext {

    @Bean
    @Profile(Profiles.INTEGRATION_TEST)
    fun vatPercentageService(): VatPercentageService {
        return mockk<VatPercentageService>(relaxed = true) // Equivalente ao Mockito.mock(VatPercentageService.class)
    }
}

Na classe de teste FindVatPercentageMockTest, fazemos três ajustes em relação à classe anterior:

  1. Apontamos @SpringBootTest para IntegrationTestMockContext::class.
  2. Como neste teste queremos apenas verificar a interação (e não o corpo da resposta), usamos um mock relaxed = true (que retorna um valor padrão, exatamente como Mockito.mock()) e limpamos o estado do mock no @BeforeEach.
  3. Escrevemos o teste que verifica que service.findByCountryCode(COUNTRY_CODE) foi invocado exatamente uma vez (verify(exactly = 1) no MockK ou verify(service, times(1)) no Mockito):
 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
package com.testwithspring.intermediate.vat

import com.testwithspring.intermediate.Profiles
import io.mockk.clearMocks
import io.mockk.verify
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.ResultActions
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get

@SpringBootTest(classes = [IntegrationTestMockContext::class])
@AutoConfigureMockMvc
@Tag("integrationTest")
@ActiveProfiles(Profiles.INTEGRATION_TEST)
class FindVatPercentageMockTest {

    companion object {
        private const val COUNTRY_CODE = "FI"
    }

    @Autowired
    private lateinit var mockMvc: MockMvc

    @Autowired
    private lateinit var service: VatPercentageService

    @BeforeEach
    fun resetMock() {
        clearMocks(service)
    }

    @Test
    fun `deve buscar a porcentagem de VAT com o codigo de pais correto`() {
        findByCountryCode(COUNTRY_CODE)

        verify(exactly = 1) { service.findByCountryCode(COUNTRY_CODE) }
    }

    private fun findByCountryCode(countryCode: String): ResultActions {
        return mockMvc.perform(
            get("/api/vat-percentage")
                .param("countryCode", countryCode)
        )
    }
}

Vamos ver agora a terceira alternativa: como criar um Stub Clássico escrito à mão!


4. Criando um Stub Clássico (VatPercentageServiceStub)

Se quisermos implementar um stub clássico (uma subclasse ou implementação de interface fixa, sem usar bibliotecas de mocking) e utilizá-lo nos nossos testes de integração, seguimos estes três passos:

Passo 1: Implementar a Classe VatPercentageServiceStub

  1. Criamos uma classe VatPercentageServiceStub que estende VatPercentageService.
  2. Passamos null para o construtor da superclasse (super(null)), já que o stub não fará chamadas HTTP.
  3. Sobrescrevemos o método findByCountryCode() retornando uma alíquota constante (24) para qualquer código de país informado:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package com.testwithspring.intermediate.vat

class VatPercentageServiceStub : VatPercentageService(null) {

    companion object {
        private const val VAT_PERCENTAGE = 24
    }

    override fun findByCountryCode(countryCode: String): VatPercentage {
        return VatPercentage(
            countryCode = countryCode,
            vatPercentage = VAT_PERCENTAGE
        )
    }
}

Passo 2: Criar a Classe de Configuração IntegrationTestClassicStubContext

Criamos uma classe de configuração que registra uma instância de VatPercentageServiceStub quando o perfil Profiles.INTEGRATION_TEST estiver ativo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package com.testwithspring.intermediate.vat

import com.testwithspring.intermediate.ExampleApplication
import com.testwithspring.intermediate.Profiles
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Import
import org.springframework.context.annotation.Profile

@Configuration
@Import(ExampleApplication::class)
class IntegrationTestClassicStubContext {

    @Bean
    @Profile(Profiles.INTEGRATION_TEST)
    fun vatPercentageService(): VatPercentageService {
        return VatPercentageServiceStub()
    }
}

Passo 3: Escrever a Classe de Teste FindVatPercentageClassicStubTest

Veja a grande vantagem de usar um Stub Clássico: nossa classe de teste de integração não precisa sequer saber que o stub existe! Não precisamos injetar VatPercentageService, não precisamos de @BeforeEach para resetar estado e não acoplamos o teste à assinatura interna do serviç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
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.intermediate.vat

import com.testwithspring.intermediate.Profiles
import org.hamcrest.Matchers.`is`
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.MediaType
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.ResultActions
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status

@SpringBootTest(classes = [IntegrationTestClassicStubContext::class])
@AutoConfigureMockMvc
@Tag("integrationTest")
@ActiveProfiles(Profiles.INTEGRATION_TEST)
class FindVatPercentageClassicStubTest {

    companion object {
        private const val COUNTRY_CODE = "FI"
        private const val VAT_PERCENTAGE = 24
    }

    @Autowired
    private lateinit var mockMvc: MockMvc

    @Test
    fun `deve retornar status HTTP 200 OK`() {
        findByCountryCode(COUNTRY_CODE)
            .andExpect(status().isOk)
    }

    @Test
    fun `deve retornar a porcentagem de VAT como JSON`() {
        findByCountryCode(COUNTRY_CODE)
            .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
    }

    @Test
    fun `deve retornar a porcentagem de VAT correta`() {
        findByCountryCode(COUNTRY_CODE)
            .andExpect(jsonPath("$.countryCode", `is`(COUNTRY_CODE)))
            .andExpect(jsonPath("$.vatPercentage", `is`(VAT_PERCENTAGE)))
    }

    private fun findByCountryCode(countryCode: String): ResultActions {
        return mockMvc.perform(
            get("/api/vat-percentage")
                .param("countryCode", countryCode)
        )
    }
}

5. Qual Técnica Devemos Utilizar? (MockK/Mockito vs Stub Clássico)

Ambas as técnicas têm prós e contras:

  • MockK / Mockito oferece suporte extremamente versátil tanto para stubbing quanto para mocking, dando controle total sobre o test double em cada teste. A desvantagem é que precisamos resetar o test double no @BeforeEach e configurar seus retornos dentro da classe de teste.
  • Stubs Clássicos são completamente invisíveis para as classes de teste de integração, tornando os testes mais simples e fáceis de manter. A desvantagem é que eles não são práticos quando precisamos retornar valores diferentes por argumento ou simular exceções específicas em cada método de teste.

Podemos escolher a técnica ideal seguindo estas três regras:

  1. Se precisarmos verificar as interações que aconteceram entre o sistema sob teste e o nosso test double, devemos criar um Mock com MockK / Mockito (não devemos reinventar a roda).
  2. Se o nosso stub precisar retornar valores diferentes em diferentes testes (ou se quisermos destacar explicitamente o valor retornado dentro do código do teste), devemos criar um Stub com MockK / Mockito.
  3. Se o nosso stub puder retornar sempre o mesmo valor constante, devemos escrever um Stub Clássico, pois ele mantém nossos testes de integração muito mais limpos e fáceis de manter.

Resumo

Nesta aula, aprendemos que:

  • Substituir o bean VatPercentageService por um test double (com MockK/Mockito ou Stub Clássico) é simples e rápido.
  • Entretanto, a grande desvantagem dessa abordagem é que nossos testes de integração ignoram completamente o código dentro de VatPercentageService que faz a chamada HTTP e deserializa a resposta da API externa!
  • Na próxima aula (Parte 2), resolveremos exatamente esse problema utilizando o WireMock para simular o servidor HTTP da API externa!