Testando RestClient com @RestClientTest

Depois de concluirmos este webinar, nós:

  • Compreenderemos como podemos autoconfigurar o sistema sob teste com @RestClientTest no Spring Boot 4.
  • Saberemos criar stubs e mocks de requisições HTTP utilizando a classe MockRestServiceServer.
  • Saberemos como escrever testes para clientes REST (RestClient e RestTemplate) com JUnit 6 e Kotlin.

Este webinar assume que:

  • Você sabe especificar o contexto de aplicação carregado pelo Spring TestContext Framework.
  • Você está familiarizado com o JUnit 6.
  • Você sabe criar um projeto que executa testes unitários, de integração e End-to-End com @Tag.

Introdução ao Sistema sob Teste

Durante este webinar, escreveremos testes para um serviço que gerencia objetos Message. Um objeto Message contém o id e o text de uma única mensagem. O código-fonte da data class Message em Kotlin é o seguinte:

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

data class Message(
    val id: Long? = null,
    val text: String = ""
)

A classe MessageService gerencia mensagens consumindo uma API REST remota. Ela possui duas dependências injetadas via construtor:

  • Ela utiliza um RestClient.Builder (ou RestTemplateBuilder) para construir o cliente HTTP que envia requisições para a API REST remota.
  • Ela utiliza um objeto MessageApiUrlBuilder para construir as URLs usadas na comunicação com a API REST.

Esta classe possui dois métodos:

  • O método create() cria uma nova mensagem enviando uma requisição POST para a API REST e retorna as informações da mensagem criada.
  • O método findById() busca as informações da mensagem solicitada enviando uma requisição GET para a API REST. Se nenhuma mensagem for encontrada (HTTP 404), este método retorna null (ou Optional.empty()).

O código-fonte da classe MessageService em Kotlin utilizando a API fluente do RestClient do Spring Boot 4 (compatível com o MockRestServiceServer do @RestClientTest) é o seguinte:

 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
package com.testwithspring.master.message

import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.stereotype.Service
import org.springframework.web.client.HttpClientErrorException
import org.springframework.web.client.RestClient
import org.springframework.web.client.body

@Service
class MessageService(
    restClientBuilder: RestClient.Builder,
    private val urlBuilder: MessageApiUrlBuilder
) {

    private val restClient: RestClient = restClientBuilder.build()

    fun create(messageText: String): Message {
        val input = Message(text = messageText)
        val url = urlBuilder.buildCreateMessageUrl()

        return restClient.post()
            .uri(url)
            .contentType(MediaType.APPLICATION_JSON)
            .body(input)
            .retrieve()
            .body<Message>()
            ?: error("A API remota retornou corpo vazio ao criar mensagem")
    }

    fun findById(id: Long): Message? {
        val url = urlBuilder.buildFindMessageUrl(id)

        return try {
            restClient.get()
                .uri(url)
                .retrieve()
                .body<Message>()
        } catch (ex: HttpClientErrorException) {
            if (ex.statusCode == HttpStatus.NOT_FOUND) {
                null
            } else {
                throw ex
            }
        }
    }
}

A classe MessageApiUrlBuilder é responsável por construir as URLs que utilizamos ao nos comunicarmos com a API REST remota. Usar uma classe construtora de URLs permite ler a URL base a partir de um arquivo de propriedades (@Value("\${message.api.base.url}")) e usar diferentes URLs base em diferentes ambientes:

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

import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import org.springframework.web.util.UriComponentsBuilder

@Component
class MessageApiUrlBuilder(
    @Value("\${message.api.base.url}") private val apiBaseUrl: String
) {

    fun buildCreateMessageUrl(): String =
        UriComponentsBuilder.fromUriString(apiBaseUrl)
            .path("/api/message")
            .build()
            .toUriString()

    fun buildFindMessageUrl(id: Long): String =
        UriComponentsBuilder.fromUriString(apiBaseUrl)
            .path("/api/message/{id}")
            .buildAndExpand(id)
            .toUriString()
}

A seguir, descobriremos como podemos configurar o sistema sob teste utilizando a autoconfiguração do Spring Boot.

Configurando o Sistema sob Teste com @RestClientTest

Podemos configurar o sistema sob teste seguindo estes passos:

  1. Anotar a classe de teste com @RestClientTest e especificar os componentes testados que devem ser adicionados ao contexto de aplicação carregado (MessageApiUrlBuilder::class e MessageService::class).
  2. Configurar o perfil ativo do Spring (@ActiveProfiles(Profiles.UNIT_TEST)). Isso nos permite carregar a URL base da API REST remota (http://localhost:9090) a partir de um arquivo de propriedades específico de teste.
  3. Anotar a classe de teste com @Tag(TestTags.UNIT_TEST).
  4. Injetar o bean MessageService na nossa classe de teste.
  5. Injetar o bean MockRestServiceServer na nossa classe de teste. Usaremos este campo quando quisermos criar stubs e mocks de requisições HTTP enviadas para a API REST.
  6. Criar um método @BeforeEach que reseta o estado do servidor mock (server.reset()) antes da execução de cada método de teste. Precisamos fazer isso porque, caso contrário, não conseguiríamos especificar novas expectativas após enviar uma requisição HTTP ao servidor mock.

Após termos configurado o sistema sob teste, o código-fonte 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
package com.testwithspring.master.message

import com.testwithspring.master.TestTags
import com.testwithspring.master.config.Profiles
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Tag
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.client.RestClientTest
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.web.client.MockRestServiceServer

@RestClientTest(components = [MessageApiUrlBuilder::class, MessageService::class])
@ActiveProfiles(Profiles.UNIT_TEST)
@Tag(TestTags.UNIT_TEST)
class MessageServiceTest(
    @Autowired private val service: MessageService,
    @Autowired private val server: MockRestServiceServer
) {

    @BeforeEach
    fun clearExpectations() {
        server.reset()
    }
}

Há dois pontos importantes que quero destacar:

  • Por padrão, a anotação @RestClientTest autoconfigura o suporte ao Jackson (ObjectMapper), além de configurar os beans RestClient.Builder, RestTemplateBuilder e MockRestServiceServer conectados em memória (sem abrir sockets TCP reais).
  • Como você deve ter notado, adicionei esta classe de teste à nossa suíte de testes unitários (TestTags.UNIT_TEST) porque ela não possui dependências de infraestrutura externa (nem banco de dados, nem servidor HTTP real). Faz todo sentido usar uma unidade ligeiramente maior aqui porque o @RestClientTest valida a serialização JSON real do Jackson e a montagem das URLs em poucos milissegundos!

Vamos avançar e descobrir como podemos fazer stubbing de chamadas de API REST com o MockRestServiceServer.

Fazendo Stubbing de Chamadas de API REST (findById)

Quando queremos configurar a resposta retornada ao invocarmos a API REST remota, seguimos estas regras:

  • Identificamos a requisição HTTP esperada invocando o método expect() da classe MockRestServiceServer. Este método recebe um objeto RequestMatcher como parâmetro e retorna um objeto ResponseActions.
  • O objeto RequestMatcher determina se nossa expectativa foi atendida. Criamos objetos RequestMatcher usando os métodos fábrica estáticos da classe MockRestRequestMatchers (como requestTo(...), method(...), header(...), jsonPath(...)).
  • O objeto ResponseActions possui dois métodos principais: se quisermos especificar mais expectativas sobre a requisição, invocamos andExpect(); se quisermos configurar a resposta HTTP simulada, invocamos andRespond() passando um ResponseCreator.
  • Criamos objetos ResponseCreator usando os métodos fábrica da classe MockRestResponseCreators (como withStatus(...) e withSuccess(...)).

Vamos colocar essas habilidades em prática e escrever os testes para o método findById() da classe MessageService:

Primeiro, criamos a classe interna @Nested chamada FindById.

Segundo, garantimos que o sistema sob teste funcione conforme o esperado quando a mensagem solicitada não é encontrada:

  1. Criar a classe interna @Nested chamada WhenMessageIsNotFound.
  2. Garantir no @BeforeEach que o MockRestServiceServer retorne o código de status HTTP 404 (withStatus(HttpStatus.NOT_FOUND)) quando tentarmos buscar a mensagem de ID 99.
  3. Verificar se o método findById(99L) retorna null.

Terceiro, garantimos que o sistema sob teste funcione conforme o esperado quando a mensagem solicitada é encontrada:

  1. Criar a classe interna @Nested chamada WhenMessageIsFound.
  2. Garantir no @BeforeEach que o MockRestServiceServer retorne o JSON da mensagem solicitada com status 200 (withSuccess(..., MediaType.APPLICATION_JSON)).
  3. Verificar se o sistema sob teste retorna um objeto Message com o id correto (99L).
  4. Garantir que o sistema sob teste retorne um objeto Message com o text correto ("Hello World!").

Após termos escrito esses testes, o código-fonte da seção FindById 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
44
45
46
47
48
49
50
51
52
    @Nested
    @DisplayName("Buscar uma única mensagem")
    inner class FindById {

        @Nested
        @DisplayName("Quando a mensagem não é encontrada")
        inner class WhenMessageIsNotFound {

            @BeforeEach
            fun returnHttpStatusCodeNotFound() {
                server.expect(requestTo("http://localhost:9090/api/message/99"))
                    .andRespond(withStatus(HttpStatus.NOT_FOUND))
            }

            @Test
            @DisplayName("Deve retornar nulo (ou Optional vazio)")
            fun shouldReturnNull() {
                val messageResult = service.findById(99L)
                assertThat(messageResult).isNull()
            }
        }

        @Nested
        @DisplayName("Quando a mensagem é encontrada")
        inner class WhenMessageIsFound {

            @BeforeEach
            fun returnFoundMessage() {
                server.expect(requestTo("http://localhost:9090/api/message/99"))
                    .andRespond(
                        withSuccess(
                            """{"id": 99, "text": "Hello World!"}""",
                            MediaType.APPLICATION_JSON
                        )
                    )
            }

            @Test
            @DisplayName("Deve retornar uma mensagem com o id correto")
            fun shouldReturnMessageWithCorrectId() {
                val found = service.findById(99L)!!
                assertThat(found.id).isEqualTo(99L)
            }

            @Test
            @DisplayName("Deve retornar uma mensagem com o texto correto")
            fun shouldReturnMessageWithCorrectText() {
                val found = service.findById(99L)!!
                assertThat(found.text).isEqualTo("Hello World!")
            }
        }
    }

Agora sabemos escrever testes para métodos que buscam informações de uma API REST remota. A seguir, descobriremos como podemos fazer mocking (verificação de interação) de chamadas de API REST usando a classe MockRestServiceServer.

Fazendo Mocking e Verificando Chamadas de API REST (create)

Quando especificamos nossas expectativas invocando o método expect() do MockRestServiceServer e queremos verificar explicitamente que a requisição HTTP esperada foi enviada X vezes com o método, cabeçalhos e corpo JSON corretos, seguimos estas regras:

  • Podemos especificar a quantidade esperada de invocações passando um objeto ExpectedCount (como ExpectedCount.times(1) ou ExpectedCount.once()) como primeiro parâmetro do método expect(). Se omitirmos esse parâmetro, o MockRestServiceServer assume que esperamos apenas uma requisição.
  • Invocamos o sistema sob teste e verificamos se a requisição HTTP esperada foi realmente enviada X vezes chamando o método server.verify() da classe MockRestServiceServer.

Vamos ver como podemos garantir que o método create() da classe MessageService crie apenas uma mensagem enviando o payload JSON correto e retorne as informações da mensagem criada:

  1. Garantir no @BeforeEach que o MockRestServiceServer espere receber exatamente uma requisição POST para http://localhost:9090/api/message com Content-Type: application/json, $.id nulo e $.text igual a "Hello World!", respondendo com o JSON da mensagem criada.
  2. Verificar se o sistema sob teste cria uma mensagem invocando a API remota uma vez (server.verify()).
  3. Garantir que o sistema sob teste retorne um Message com o id correto (99L).
  4. Verificar se o sistema sob teste retorna um Message com o texto correto ("Hello World!").

Após termos escrito todos os testes, o código-fonte completo de MessageServiceTest 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
 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
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
package com.testwithspring.master.message

import com.testwithspring.master.TestTags
import com.testwithspring.master.config.Profiles
import org.assertj.core.api.Assertions.assertThat
import org.hamcrest.Matchers.`is`
import org.hamcrest.Matchers.nullValue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
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.client.RestClientTest
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.web.client.ExpectedCount.times
import org.springframework.test.web.client.MockRestServiceServer
import org.springframework.test.web.client.match.MockRestRequestMatchers.header
import org.springframework.test.web.client.match.MockRestRequestMatchers.jsonPath
import org.springframework.test.web.client.match.MockRestRequestMatchers.method
import org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo
import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus
import org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess

@RestClientTest(components = [MessageApiUrlBuilder::class, MessageService::class])
@ActiveProfiles(Profiles.UNIT_TEST)
@Tag(TestTags.UNIT_TEST)
class MessageServiceTest(
    @Autowired private val service: MessageService,
    @Autowired private val server: MockRestServiceServer
) {

    @BeforeEach
    fun clearExpectations() {
        server.reset()
    }

    @Nested
    @DisplayName("Buscar uma única mensagem")
    inner class FindById {

        @Nested
        @DisplayName("Quando a mensagem não é encontrada")
        inner class WhenMessageIsNotFound {

            @BeforeEach
            fun returnHttpStatusCodeNotFound() {
                server.expect(requestTo("http://localhost:9090/api/message/99"))
                    .andRespond(withStatus(HttpStatus.NOT_FOUND))
            }

            @Test
            @DisplayName("Deve retornar nulo")
            fun shouldReturnNull() {
                val messageResult = service.findById(99L)
                assertThat(messageResult).isNull()
            }
        }

        @Nested
        @DisplayName("Quando a mensagem é encontrada")
        inner class WhenMessageIsFound {

            @BeforeEach
            fun returnFoundMessage() {
                server.expect(requestTo("http://localhost:9090/api/message/99"))
                    .andRespond(
                        withSuccess(
                            """{"id": 99, "text": "Hello World!"}""",
                            MediaType.APPLICATION_JSON
                        )
                    )
            }

            @Test
            @DisplayName("Deve retornar uma mensagem com o id correto")
            fun shouldReturnMessageWithCorrectId() {
                val found = service.findById(99L)!!
                assertThat(found.id).isEqualTo(99L)
            }

            @Test
            @DisplayName("Deve retornar uma mensagem com o texto correto")
            fun shouldReturnMessageWithCorrectText() {
                val found = service.findById(99L)!!
                assertThat(found.text).isEqualTo("Hello World!")
            }
        }
    }

    @Nested
    @DisplayName("Criar uma nova mensagem")
    inner class CreateMessage {

        @BeforeEach
        fun returnCreatedMessageAsJson() {
            server.expect(times(1), method(HttpMethod.POST))
                .andExpect(requestTo("http://localhost:9090/api/message"))
                .andExpect(header("Content-Type", MediaType.APPLICATION_JSON_VALUE))
                .andExpect(jsonPath("$.id", nullValue()))
                .andExpect(jsonPath("$.text", `is`("Hello World!")))
                .andRespond(
                    withSuccess(
                        """{"id": 99, "text": "Hello World!"}""",
                        MediaType.APPLICATION_JSON
                    )
                )
        }

        @Test
        @DisplayName("Deve criar uma nova mensagem invocando a API remota uma vez")
        fun shouldCreateNewMessageByInvokingRemoteApiOnce() {
            service.create("Hello World!")
            server.verify()
        }

        @Test
        @DisplayName("Deve retornar uma mensagem com o id correto")
        fun shouldReturnMessageWithCorrectId() {
            val created = service.create("Hello World!")
            assertThat(created.id).isEqualTo(99L)
        }

        @Test
        @DisplayName("Deve retornar uma mensagem com o texto correto")
        fun shouldReturnMessageWithCorrectText() {
            val created = service.create("Hello World!")
            assertThat(created.text).isEqualTo("Hello World!")
        }
    }
}

Agora sabemos escrever testes para clientes REST com Spring Boot 4 e JUnit 6. Vamos resumir o que aprendemos neste webinar.

Resumo

Este webinar nos ensinou três coisas:

  • Podemos autoconfigurar o sistema sob teste (incluindo Jackson, RestClient.Builder / RestTemplateBuilder e MockRestServiceServer) utilizando a anotação @RestClientTest.
  • Podemos fazer stub e mock de requisições HTTP síncronas utilizando a classe MockRestServiceServer.
  • Devemos lembrar de resetar o estado do servidor mock (server.reset()) no @BeforeEach antes que cada método de teste seja executado.