Testes de Contrato (CDC), Parte 2: Consumidor e Stub Runner

Ao finalizar esta aula (baseada no webinar bônus), seremos capazes de:

  • Criar um projeto Gradle (Kotlin DSL) ou Maven para um serviço consumidor REST.
  • Implementar um serviço consumidor que consome a API fornecida pelo serviço provedor (produtor).
  • Compreender como podemos garantir que o contrato entre o nosso consumidor e o produtor não sofreu alterações.
  • Escrever testes automatizados para o nosso serviço consumidor utilizando o Spring Cloud Contract Stub Runner.

Esta aula assume que:

Vamos começar criando o projeto para o nosso serviço consumidor REST.

Criando um Projeto para um Serviço Consumidor REST

Como a nossa aplicação de exemplo utiliza o Spring Boot 4.1.2 com Kotlin 2.4.21, a estrutura do nosso projeto no Gradle 9.8.2 (Kotlin DSL) (ou Maven) é essencialmente a de um projeto Spring Boot convencional. Contudo, há dois pontos fundamentais que precisamos destacar na configuração do build:

Primeiro, precisamos adicionar o BOM (Bill of Materials) do Spring Cloud Contract (spring-cloud-contract-dependencies) ao gerenciamento de dependências do nosso build. Esse BOM especifica e alinha as versões de todas as bibliotecas do ecossistema Spring Cloud Contract (versão 5.0.0 na stack moderna com Spring Boot 4.1.2; ou 2.0.1.RELEASE no projeto Maven legado original).

No Gradle 9.8.2 (build.gradle.kts), declaramos a importação do BOM dentro do bloco dependencyManagement (ou via platform no bloco dependencies):

1
2
3
4
5
dependencyManagement {
    imports {
        mavenBom("org.springframework.cloud:spring-cloud-contract-dependencies:5.0.0")
    }
}

Se estivermos utilizando um arquivo pom.xml no Maven, adicionamos o seguinte trecho:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-contract-dependencies</artifactId>
            <version>5.0.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

Segundo, como queremos substituir o serviço produtor real por um stub durante a execução dos testes, precisamos declarar a dependência spring-cloud-starter-contract-stub-runner no escopo de teste. Esse componente é capaz de baixar e executar automaticamente os stubs WireMock gerados pelo projeto do nosso serviço produtor REST.

Podemos declarar essa dependência adicionando o seguinte trecho à seção dependencies do nosso arquivo build.gradle.kts:

1
2
3
dependencies {
    testImplementation("org.springframework.cloud:spring-cloud-starter-contract-stub-runner")
}

Ou, no caso de um arquivo pom.xml do Maven:

1
2
3
4
5
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-contract-stub-runner</artifactId>
    <scope>test</scope>
</dependency>

Agora criamos um projeto configurado para compilar e executar o nosso serviço consumidor REST. A seguir, implementaremos o código do nosso serviço consumidor REST.

Implementando um Serviço Consumidor REST

Como nos lembramos da Parte 1, o nosso serviço produtor REST retorna as informações de uma única tarefa (Task). Agora, escreveremos um serviço consumidor REST que invoca o serviço produtor REST e retorna as informações devolvidas por ele. Podemos implementar o nosso serviço consumidor seguindo estes passos:

Primeiro, precisamos criar uma classe que armazene os dados de uma tarefa. Em Kotlin 2.4, utilizamos uma data class imutável. Após escrevermos a classe Task, o seu código-fonte fica da seguinte forma:

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

data class Task(
    val id: Long,
    val title: String
)

(Nota: Na versão original em Java, Task era uma classe com campos private final Long id e private final String title, construtor parametrizado e métodos getters).

Segundo, precisamos escrever o serviço consumidor REST propriamente dito seguindo estes passos:

  1. Criar uma nova classe de controller (@RestController) e garantir que seus métodos vinculem o valor retornado diretamente ao corpo da resposta HTTP retornada.
  2. Adicionar um campo RestTemplate (ou RestClient no Spring Boot 4) à nossa classe de controller e escrever um construtor que injete e construa a instância de RestTemplate a partir do RestTemplateBuilder.
  3. Escrever um método de controller que processe requisições HTTP GET enviadas para o caminho: /api/task/{id}.
  4. Buscar a tarefa solicitada a partir do nosso serviço produtor REST (http://localhost:8080/api/task/{id}) e retornar a tarefa encontrada.

Após escrevermos a nossa classe de controller em Kotlin, o 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
18
19
20
21
22
package com.testwithspring.master.webinar

import org.springframework.boot.web.client.RestTemplateBuilder
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.client.RestTemplate

@RestController
class TaskController(restTemplateBuilder: RestTemplateBuilder) {

    private val restTemplate: RestTemplate = restTemplateBuilder.build()

    @GetMapping("/api/task/{id}")
    fun findById(@PathVariable("id") id: Long): Task? {
        return restTemplate.getForObject(
            "http://localhost:8080/api/task/{id}",
            Task::class.java,
            id
        )
    }
}

Observação sobre as portas dos serviços:

Antes de continuarmos, vale destacar que os nossos serviços produtor e consumidor escutam em portas diferentes para evitar conflitos quando executados localmente:

  • O nosso serviço produtor REST escuta na porta 8080.
  • O nosso serviço consumidor REST escuta na porta 9000.

Já implementamos o nosso serviço consumidor REST. Vamos avançar e descobrir como podemos escrever testes automatizados para o nosso serviço consumidor.

Escrevendo Testes para o Nosso Serviço Consumidor REST

Quando escrevemos testes para um serviço consumidor REST utilizando contratos dirigidos pelo consumidor (Consumer-Driven Contracts), podemos atingir dois objetivos complementares: garantir que o contrato fornecido pelo serviço produtor REST não mudou ou verificar se o próprio serviço consumidor REST está funcionando conforme o esperado de ponta a ponta.

Pré-requisito importante antes de executar os testes:

Antes de podermos executar esses testes no projeto consumidor, precisamos gerar os stubs WireMock que “implementam” os nossos contratos dirigidos pelo consumidor e publicar esses artefatos de stub no nosso repositório Maven local (~/.m2/repository).

Podemos fazer isso executando o comando ./gradlew clean publishToMavenLocal (em projetos Gradle configurados com o plugin maven-publish) ou mvn clean install no diretório raiz do projeto que compila e empacota o nosso serviço produtor REST.

A seguir, descobriremos como podemos garantir que o contrato fornecido pelo nosso serviço produtor REST não sofreu alterações.

Escrevendo Testes para o Nosso Contrato

Se quisermos garantir diretamente que o contrato fornecido pelo nosso serviço produtor REST não mudou, devemos seguir estes passos:

Primeiro, precisamos criar uma nova classe de teste e configurar o sistema sob teste (System Under Test) seguindo estas etapas:

  1. Garantir que os nossos testes sejam executados com o suporte do Spring TestContext Framework no JUnit Jupiter 6.0.3 (integrado automaticamente pela anotação @SpringBootTest, que no JUnit 4 exigia @RunWith(SpringRunner.class)).
  2. Habilitar as melhorias de teste fornecidas pelo Spring Boot (@SpringBootTest).
  3. Garantir que os nossos métodos de teste utilizem os stubs WireMock gerados pelo projeto do nosso serviço produtor REST por meio da anotação @AutoConfigureStubRunner. Ao fazer isso, devemos configurar stubsMode = StubRunnerProperties.StubsMode.LOCAL para garantir que os stubs sejam buscados do repositório Maven local, além de especificar nas coordenadas (ids) que esses stubs devem escutar na porta 8080.

Após configurarmos o sistema sob teste, o código-fonte da nossa classe de teste fica da seguinte forma:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package com.testwithspring.master.webinar

import org.springframework.boot.test.context.SpringBootTest
import org.springframework.cloud.contract.stubrunner.spring.AutoConfigureStubRunner
import org.springframework.cloud.contract.stubrunner.spring.StubRunnerProperties

@SpringBootTest
@AutoConfigureStubRunner(
    stubsMode = StubRunnerProperties.StubsMode.LOCAL,
    ids = ["com.testwithspring.master.webinar:contract-rest-service:0.0.1-SNAPSHOT:stubs:8080"]
)
class TaskContractTest {

}

Segundo, precisamos adicionar um campo RestTemplate à nossa classe de teste e implementar um método de configuração (@BeforeEach no JUnit Jupiter 6, equivalente ao @Before do JUnit 4) que crie uma nova instância de RestTemplate e a armazene no campo restTemplate.

Após criarmos o RestTemplate que será utilizado pelos nossos métodos de 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
package com.testwithspring.master.webinar

import org.junit.jupiter.api.BeforeEach
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.cloud.contract.stubrunner.spring.AutoConfigureStubRunner
import org.springframework.cloud.contract.stubrunner.spring.StubRunnerProperties
import org.springframework.web.client.RestTemplate

@SpringBootTest
@AutoConfigureStubRunner(
    stubsMode = StubRunnerProperties.StubsMode.LOCAL,
    ids = ["com.testwithspring.master.webinar:contract-rest-service:0.0.1-SNAPSHOT:stubs:8080"]
)
class TaskContractTest {

    private lateinit var restTemplate: RestTemplate

    @BeforeEach
    fun setup() {
        restTemplate = RestTemplate()
    }
}

Terceiro, precisamos garantir que o nosso contrato retorne a resposta HTTP correta quando enviarmos uma requisição GET para a URL que aciona o contrato (http://localhost:8080/api/task/1). Podemos fazer isso seguindo estes passos:

  • Garantir que o nosso contrato retorne o código de status HTTP 200 (HttpStatus.OK).
  • Verificar se o nosso contrato retorna uma tarefa que possui o id correto (1L).
  • Garantir que o nosso contrato retorne uma tarefa que possui o title correto ("Write our first contract").

Após escrevermos os métodos de teste necessários, o código-fonte completo da nossa classe de teste fica da seguinte forma:

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

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.cloud.contract.stubrunner.spring.AutoConfigureStubRunner
import org.springframework.cloud.contract.stubrunner.spring.StubRunnerProperties
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.client.RestTemplate

@SpringBootTest
@AutoConfigureStubRunner(
    stubsMode = StubRunnerProperties.StubsMode.LOCAL,
    ids = ["com.testwithspring.master.webinar:contract-rest-service:0.0.1-SNAPSHOT:stubs:8080"]
)
class TaskContractTest {

    private lateinit var restTemplate: RestTemplate

    @BeforeEach
    fun setup() {
        restTemplate = RestTemplate()
    }

    @Test
    fun shouldReturnHttpStatusCodeOk() {
        val taskResponse: ResponseEntity<Task> = restTemplate.getForEntity(
            "http://localhost:8080/api/task/1",
            Task::class.java
        )
        assertThat(taskResponse.statusCode).isEqualTo(HttpStatus.OK)
    }

    @Test
    fun shouldReturnTaskWithCorrectId() {
        val taskResponse: ResponseEntity<Task> = restTemplate.getForEntity(
            "http://localhost:8080/api/task/1",
            Task::class.java
        )

        val task: Task? = taskResponse.body
        assertThat(task?.id).isEqualByComparingTo(1L)
    }

    @Test
    fun shouldReturnTaskWithCorrectTitle() {
        val taskResponse: ResponseEntity<Task> = restTemplate.getForEntity(
            "http://localhost:8080/api/task/1",
            Task::class.java
        )

        val task: Task? = taskResponse.body
        assertThat(task?.title).isEqualTo("Write our first contract")
    }
}

Quando executamos os nossos testes, vemos que todos passam! Agora conseguimos verificar se o contrato fornecido por um produtor REST não mudou. No entanto, esse teste isolado avalia apenas a comunicação direta com o stub e não garante que o controller do nosso serviço consumidor REST esteja funcionando conforme o esperado. Vamos avançar e descobrir como podemos escrever testes para o nosso serviço consumidor REST.

Escrevendo Testes para o Nosso Serviço Consumidor REST

Quando queremos garantir que o nosso serviço consumidor REST (TaskController) está funcionando conforme o esperado, devemos seguir estes passos:

Primeiro, precisamos criar uma nova classe de teste e configurar o sistema sob teste seguindo estas etapas:

  1. Garantir que os nossos testes sejam executados com o suporte do Spring no JUnit Jupiter 6 (via @SpringBootTest, substituindo o @RunWith(SpringRunner.class) do JUnit 4).
  2. Habilitar as melhorias de teste fornecidas pelo Spring Boot (@SpringBootTest).
  3. Habilitar a autoconfiguração do framework Spring MVC Test (@AutoConfigureMockMvc).
  4. Garantir que os nossos métodos de teste utilizem os stubs WireMock gerados pelo projeto do nosso serviço produtor REST (@AutoConfigureStubRunner). Ao fazer isso, precisamos garantir que esses stubs sejam buscados do repositório Maven local (StubsMode.LOCAL) e que escutem na porta 8080.

Após configurarmos o sistema sob teste, o código-fonte da nossa classe de teste fica da seguinte forma:

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

import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.cloud.contract.stubrunner.spring.AutoConfigureStubRunner
import org.springframework.cloud.contract.stubrunner.spring.StubRunnerProperties

@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureStubRunner(
    stubsMode = StubRunnerProperties.StubsMode.LOCAL,
    ids = ["com.testwithspring.master.webinar:contract-rest-service:0.0.1-SNAPSHOT:stubs:8080"]
)
class FindTaskByIdWhenTaskIsFoundTest {

}

Segundo, precisamos adicionar um campo MockMvc à nossa classe de teste e garantir que o container do Spring injete a instância real de MockMvc nesse campo utilizando @Autowired.

Após adicionarmos esse campo à nossa classe de teste, o 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
18
19
20
package com.testwithspring.master.webinar

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.cloud.contract.stubrunner.spring.AutoConfigureStubRunner
import org.springframework.cloud.contract.stubrunner.spring.StubRunnerProperties
import org.springframework.test.web.servlet.MockMvc

@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureStubRunner(
    stubsMode = StubRunnerProperties.StubsMode.LOCAL,
    ids = ["com.testwithspring.master.webinar:contract-rest-service:0.0.1-SNAPSHOT:stubs:8080"]
)
class FindTaskByIdWhenTaskIsFoundTest {

    @Autowired
    private lateinit var mockMvc: MockMvc
}

Terceiro, precisamos garantir que o nosso serviço consumidor REST (integrado ao stub do serviço produtor REST) funcione conforme o esperado quando a tarefa solicitada for encontrada. Podemos escrever esses testes seguindo estes passos:

  1. Garantir que o serviço retorne o código de status HTTP 200 (Ok).
  2. Verificar se o serviço retorna as informações da tarefa encontrada no formato JSON (MediaType.APPLICATION_JSON, que substituiu a constante depreciada MediaType.APPLICATION_JSON_UTF8 das versões antigas do Spring).
  3. Garantir que o serviço retorne o payload JSON com as informações corretas (id igual a 1 e title igual a "Write our first contract").

Após escrevermos esses testes, o código-fonte completo da nossa classe de teste fica da seguinte forma:

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

import org.hamcrest.Matchers.`is`
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.cloud.contract.stubrunner.spring.AutoConfigureStubRunner
import org.springframework.cloud.contract.stubrunner.spring.StubRunnerProperties
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.MockMvc
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
@AutoConfigureMockMvc
@AutoConfigureStubRunner(
    stubsMode = StubRunnerProperties.StubsMode.LOCAL,
    ids = ["com.testwithspring.master.webinar:contract-rest-service:0.0.1-SNAPSHOT:stubs:8080"]
)
class FindTaskByIdWhenTaskIsFoundTest {

    @Autowired
    private lateinit var mockMvc: MockMvc

    @Test
    fun shouldReturnHttpStatusCodeOk() {
        mockMvc.perform(get("/api/task/{id}", 1L))
            .andExpect(status().isOk)
    }

    @Test
    fun shouldReturnFoundTaskAsJsonOk() {
        mockMvc.perform(get("/api/task/{id}", 1L))
            .andExpect(content().contentType(MediaType.APPLICATION_JSON))
    }

    @Test
    fun shouldReturnInformationOfCorrectTask() {
        mockMvc.perform(get("/api/task/{id}", 1L))
            .andExpect(jsonPath("$.id", `is`(1)))
            .andExpect(jsonPath("$.title", `is`("Write our first contract")))
    }
}

Quando executamos os nossos testes, vemos que todos passam! Agora sabemos como implementar um serviço consumidor REST e garantir que ele esteja funcionando exatamente conforme o esperado usando stubs gerados a partir de contratos. Vamos resumir o que aprendemos nesta aula.

Resumo

Esta aula nos ensinou quatro conceitos fundamentais:

  • O Spring Cloud Contract Stub Runner é capaz de baixar e executar automaticamente os stubs WireMock gerados pelo projeto de um serviço produtor REST.
  • Antes de podermos executar esses testes no consumidor, precisamos gerar os stubs WireMock que “implementam” os nossos contratos dirigidos pelo consumidor e instalar/publicar esses stubs no repositório Maven local para que possam ser utilizados pelos nossos testes de integração.
  • Podemos configurar os stubs inicializados utilizando a anotação @AutoConfigureStubRunner.
  • Ao escrevermos testes com o Stub Runner, podemos tanto garantir que o contrato fornecido pelo nosso serviço produtor REST não sofreu alterações quanto verificar se o nosso próprio serviço consumidor REST está funcionando conforme o esperado.

Obtenha o código-fonte original no GitHub