Simulando APIs REST Externas: Parte 2 (WireMock na Prática)

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

  • Saberemos declarar a dependência do WireMock no Gradle e no Maven.
  • Saberemos como configurar o servidor HTTP do WireMock nos nossos testes de integração, simular respostas HTTP/JSON (stubbing) e verificar requisições HTTP recebidas (mocking).
  • Entenderemos exatamente quando devemos usar o WireMock em vez de substituir o bean do serviço por um mock/stub em memória.

1. Obtendo as Dependências Necessárias

Antes de podermos utilizar o WireMock nos nossos testes de integração, precisamos declarar a dependência do WireMock no nosso script de build.

Se estivermos utilizando Gradle (Kotlin DSL), adicionamos a dependência ao source set de testes de integração no arquivo build.gradle.kts:

1
2
3
4
5
dependencies {
    // WireMock standalone para JUnit Jupiter / Spring Boot 4
    "integrationTestImplementation"("org.wiremock:wiremock-standalone:3.12.1")
    // (Ou com.github.tomakehurst:wiremock-jre8 em projetos anteriores)
}

Se estivermos utilizando Maven, adicionamos a seguinte dependência ao arquivo pom.xml:

1
2
3
4
5
6
<dependency>
    <groupId>org.wiremock</groupId>
    <artifactId>wiremock-standalone</artifactId>
    <version>3.12.1</version>
    <scope>test</scope>
</dependency>

Vamos descobrir agora como podemos configurar o servidor do WireMock nos nossos testes de integração.


2. Configurando o Servidor WireMock

Na aula anterior (Parte 1), substituímos o bean VatPercentageService por um test double quando executávamos nossos testes de integração. Fizemos isso porque essa classe obtém a porcentagem de VAT enviando uma requisição HTTP GET para uma API REST externa (http://localhost:8080/api/external/vat-percentage?countryCode=...), e queríamos eliminar a dependência externa.

Agora, com o WireMock, não substituiremos mais o bean VatPercentageService! Em vez disso, manteremos o VatPercentageService real (anotado normalmente com @Service) e iniciaremos um servidor HTTP real do WireMock ouvindo em localhost:8080 durante o teste:

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

import org.springframework.stereotype.Service
import org.springframework.web.client.RestTemplate

@Service
class VatPercentageService(
    private val restTemplate: RestTemplate
) {
    fun findByCountryCode(countryCode: String): VatPercentage {
        return restTemplate.getForObject(
            "http://localhost:8080/api/external/vat-percentage?countryCode=$countryCode",
            VatPercentage::class.java
        ) ?: throw IllegalStateException("Resposta vazia da API externa")
    }
}

Quando removemos todo o código específico do MockK/Mockito da nossa classe de teste de integração, o esqueleto inicial de FindVatPercentageStubTest fica assim:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@SpringBootTest(classes = [ExampleApplication::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
}

Para iniciarmos o servidor WireMock antes de cada método de teste e pará-lo após cada método de teste:

  • No JUnit 4 clássico, utilizávamos a rule @Rule public WireMockRule wireMockRule = new WireMockRule(options().bindAddress("localhost").port(8080));.
  • No JUnit 6 (Jupiter), podemos usar a extensão oficial @RegisterExtension val wireMockServer = WireMockExtension.newInstance().options(wireMockConfig().bindAddress("localhost").port(8080)).configureStaticDsl(true).build() (ou a anotação declarativa @WireMockTest(httpPort = 8080)).

Veja como fica nossa classe de teste com o servidor WireMock configurado na porta 8080:

 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
package com.testwithspring.intermediate.vat

import com.github.tomakehurst.wiremock.core.WireMockConfiguration.options
import com.github.tomakehurst.wiremock.junit5.WireMockExtension
import com.testwithspring.intermediate.ExampleApplication
import com.testwithspring.intermediate.Profiles
import org.hamcrest.Matchers.`is`
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
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 = [ExampleApplication::class])
@AutoConfigureMockMvc
@Tag("integrationTest")
@ActiveProfiles(Profiles.INTEGRATION_TEST)
class FindVatPercentageStubTest {

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

        @JvmField
        @RegisterExtension
        val wireMockRule: WireMockExtension = WireMockExtension.newInstance()
            .options(
                options()
                    .bindAddress("localhost")
                    .port(8080)
            )
            .configureStaticDsl(true)
            .build()
    }

    @Autowired
    private lateinit var mockMvc: MockMvc

    // Métodos de teste...
}

Nota: Como os valores padrão do WireMock já são bindAddress("localhost") e port(8080), não precisaríamos passar essas opções explicitamente se usássemos a porta padrão 8080. Porém, configurar options().bindAddress(...).port(...) mostra exatamente como customizar o host e a porta do servidor WireMock!

A seguir, vamos descobrir como configurar a resposta HTTP retornada pelo WireMock (stubbing).


3. Configurando a Resposta HTTP Retornada (Stubbing com WireMock)

Nossos testes de integração assumem que a API REST externa retorna a alíquota 24 quando invocada com o código de país 'FI'. Podemos configurar essa resposta HTTP seguindo estes quatro passos:

  1. Criar um método de setup anotado com @BeforeEach.
  2. Invocar o método estático givenThat() (ou stubFor()) da classe WireMock.
  3. Configurar o método HTTP esperado (WireMock.get(...)) e a URL esperada (urlEqualTo("/api/external/vat-percentage?countryCode=FI")). Repare que ao configurar a URL esperada no WireMock, não informamos o protocolo (http://), o host (localhost) nem a porta (8080) — apenas o caminho e a query string!
  4. Configurar o código de status HTTP retornado (200), o cabeçalho Content-Type (application/json;charset=UTF-8) e o documento JSON no corpo da resposta (withBody(...)).

Depois de configurarmos a resposta dessa forma, nosso método @BeforeEach fica assim:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
    @BeforeEach
    fun returnVatPercentage() {
        givenThat(
            WireMock.get(urlEqualTo("/api/external/vat-percentage?countryCode=FI"))
                .willReturn(
                    aResponse()
                        .withStatus(200)
                        .withHeader("Content-Type", "application/json;charset=UTF-8")
                        .withBody(
                            """
                            {
                                "countryCode": "FI",
                                "vatPercentage": 24
                            }
                            """.trimIndent()
                        )
                )
        )
    }

Embora isso funcione perfeitamente, esse código ainda está um pouco verboso demais. Podemos torná-lo muito mais limpo e expressivo fazendo duas melhorias:

1ª Melhoria: Separar os Query Parameters da URL com urlMatching e withQueryParam

Se a requisição HTTP esperada tiver múltiplos parâmetros de query (?a=1&b=2&c=3), usar urlEqualTo cria uma string longa e frágil (que quebra até se a ordem dos parâmetros mudar!). Por isso, é muito melhor:

  1. Ignorar os parâmetros de query ao configurar o caminho da URL usando urlMatching("/api/external/vat-percentage.+") (ou urlPathEqualTo("/api/external/vat-percentage")).
  2. Verificar o parâmetro de query separadamente encadeando .withQueryParam("countryCode", equalTo("FI")):
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
    @BeforeEach
    fun returnVatPercentage() {
        givenThat(
            WireMock.get(urlMatching("/api/external/vat-percentage.+"))
                .withQueryParam("countryCode", equalTo("FI"))
                .willReturn(
                    aResponse()
                        .withStatus(200)
                        .withHeader("Content-Type", "application/json;charset=UTF-8")
                        .withBody(
                            """
                            {
                                "countryCode": "FI",
                                "vatPercentage": 24
                            }
                            """.trimIndent()
                        )
                )
        )
    }

2ª Melhoria: Simplificar a Resposta JSON com okJson()

Em vez de construirmos manualmente aResponse().withStatus(200).withHeader("Content-Type", ...).withBody(...), podemos utilizar o método fábrica okJson() da classe WireMock! Ele recebe a string JSON como argumento e já configura automaticamente o status HTTP 200 OK e o cabeçalho Content-Type: application/json:

 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
84
85
86
87
88
89
90
91
92
93
94
95
96
97
package com.testwithspring.intermediate.vat

import com.github.tomakehurst.wiremock.client.WireMock
import com.github.tomakehurst.wiremock.client.WireMock.equalTo
import com.github.tomakehurst.wiremock.client.WireMock.givenThat
import com.github.tomakehurst.wiremock.client.WireMock.okJson
import com.github.tomakehurst.wiremock.client.WireMock.urlMatching
import com.github.tomakehurst.wiremock.core.WireMockConfiguration.options
import com.github.tomakehurst.wiremock.junit5.WireMockExtension
import com.testwithspring.intermediate.ExampleApplication
import com.testwithspring.intermediate.Profiles
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.junit.jupiter.api.extension.RegisterExtension
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 = [ExampleApplication::class])
@AutoConfigureMockMvc
@Tag("integrationTest")
@ActiveProfiles(Profiles.INTEGRATION_TEST)
class FindVatPercentageStubTest {

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

        @JvmField
        @RegisterExtension
        val wireMockRule: WireMockExtension = WireMockExtension.newInstance()
            .options(
                options()
                    .bindAddress("localhost")
                    .port(8080)
            )
            .configureStaticDsl(true)
            .build()
    }

    @Autowired
    private lateinit var mockMvc: MockMvc

    @BeforeEach
    fun returnVatPercentage() {
        givenThat(
            WireMock.get(urlMatching("/api/external/vat-percentage.+"))
                .withQueryParam("countryCode", equalTo("FI"))
                .willReturn(
                    okJson(
                        """
                        {
                            "countryCode": "FI",
                            "vatPercentage": 24
                        }
                        """.trimIndent()
                    )
                )
        )
    }

    @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)
        )
    }
}

Agora que dominamos o stubbing com WireMock, vamos ver como verificar se o servidor WireMock recebeu uma requisição HTTP específica (mocking).


4. Verificando as Requisições HTTP Recebidas (Mocking com WireMock)

Se quisermos verificar que nossa aplicação enviou uma determinada requisição HTTP para a API externa, seguimos estes dois passos:

Passo 1: Sobrescrever a Resposta Padrão do WireMock no @BeforeEach

Por padrão, o WireMock retorna erro 404 Not Found (fazendo RestTemplate lançar exceção) caso receba uma requisição HTTP para a qual nenhum stub foi configurado. Para podermos focar na verificação da requisição enviada no teste (verify), configuramos no @BeforeEach uma resposta padrão 200 OK para qualquer método e qualquer URL usando givenThat(any(anyUrl()).willReturn(ok())):

1
2
3
4
5
6
    @BeforeEach
    fun configureDefaultResponse() {
        givenThat(
            any(anyUrl()).willReturn(ok())
        )
    }

Passo 2: Escrever o Método de Teste com verify(1, getRequestedFor(...))

Primeiro, podemos verificar a URL completa usando urlEqualTo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
    @Test
    fun `deve buscar a porcentagem de VAT na API externa (usando urlEqualTo)`() {
        findByCountryCode(COUNTRY_CODE)

        verify(
            1,
            getRequestedFor(
                urlEqualTo("/api/external/vat-percentage?countryCode=FI")
            )
        )
    }

E, aplicando a mesma boa prática que aprendemos na seção anterior para evitar URLs longas com múltiplos query parameters, separamos a verificação do caminho da URL (urlMatching) da verificação do parâmetro countryCode (withQueryParam):

 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
package com.testwithspring.intermediate.vat

import com.github.tomakehurst.wiremock.client.WireMock.any
import com.github.tomakehurst.wiremock.client.WireMock.anyUrl
import com.github.tomakehurst.wiremock.client.WireMock.equalTo
import com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor
import com.github.tomakehurst.wiremock.client.WireMock.givenThat
import com.github.tomakehurst.wiremock.client.WireMock.ok
import com.github.tomakehurst.wiremock.client.WireMock.urlMatching
import com.github.tomakehurst.wiremock.client.WireMock.verify
import com.github.tomakehurst.wiremock.core.WireMockConfiguration.options
import com.github.tomakehurst.wiremock.junit5.WireMockExtension
import com.testwithspring.intermediate.ExampleApplication
import com.testwithspring.intermediate.Profiles
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
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 = [ExampleApplication::class])
@AutoConfigureMockMvc
@Tag("integrationTest")
@ActiveProfiles(Profiles.INTEGRATION_TEST)
class FindVatPercentageMockTest {

    companion object {
        private const val COUNTRY_CODE = "FI"

        @JvmField
        @RegisterExtension
        val wireMockRule: WireMockExtension = WireMockExtension.newInstance()
            .options(
                options()
                    .bindAddress("localhost")
                    .port(8080)
            )
            .configureStaticDsl(true)
            .build()
    }

    @Autowired
    private lateinit var mockMvc: MockMvc

    @BeforeEach
    fun configureDefaultResponse() {
        givenThat(
            any(anyUrl()).willReturn(ok())
        )
    }

    @Test
    fun `deve buscar a porcentagem de VAT na API externa com o parametro countryCode correto`() {
        findByCountryCode(COUNTRY_CODE)

        verify(
            1,
            getRequestedFor(urlMatching("/api/external/vat-percentage.+"))
                .withQueryParam("countryCode", equalTo(COUNTRY_CODE))
        )
    }

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

5. Quando Devemos Utilizar o WireMock?

A única coisa que determina se devemos usar o WireMock (em vez de substituir o bean do serviço por um mock/stub com MockK/Mockito) é a complexidade do código que consome a API REST externa.

Podemos tomar essa decisão guiados por estas duas regras práticas:

  1. Quando NÃO usar o WireMock: Se o nosso código cliente simplesmente envia uma requisição HTTP para a API REST externa e retorna a resposta exatamente como veio (um mero repasse sem lógica de transformação), não precisamos subir um servidor HTTP com WireMock — usar um stub com MockK/Mockito ou um Stub Clássico é mais simples e rápido, pois não há lógica complexa no cliente HTTP que estejamos deixando de testar.
  2. Quando USAR o WireMock: Se o nosso código envia requisições HTTP para a API REST externa e manipula/transforma a resposta retornada, trata códigos de erro HTTP customizados, aplica retries/timeouts ou combina dados de múltiplas fontes externas, devemos usar o WireMock! Nesses casos, o código do cliente HTTP é complexo o suficiente para exigir testes automatizados reais de serialização/deserialização JSON e protocolo HTTP, e o WireMock nos permite testar tudo isso de ponta a ponta.

Resumo

Nesta aula bônus, aprendemos três pontos essenciais:

  • Podemos configurar e gerenciar o ciclo de vida do servidor HTTP do WireMock nos nossos testes usando WireMockExtension (JUnit 6) ou WireMockRule (JUnit 4).
  • Com givenThat(get(urlMatching(...)).withQueryParam(...).willReturn(okJson(...))) e verify(1, getRequestedFor(...)), exercitamos o cliente HTTP real (RestTemplate / RestClient) e o mapeamento JSON do Jackson sem depender da rede externa.
  • Devemos usar o WireMock sempre que o código que consome a API REST externa tiver lógica de negócio, transformação de payloads, agregação de múltiplas chamadas ou tratamento de erros HTTP que mereça ser testado.