En este tutorial vamos a explorar como hacer contract testing con spring, más concretamente con el proyecto Spring Cloud que tiene el subproyecto spring cloud contract. Contract testing puede ser algo desconocido, pero ciertamente útil en el contexto de arquitecturas complejas u orientadas a microservicios.
Índice de contenidos
- 1. Introducción
- 2. Entorno
- 3. Contract testing
- 4. Porque necesitamos contract testing
- 5. Spring cloud contract
- 6. Ejemplo práctico
- 7. Usando git como broker
- 8. Conclusiones
- 9. Referencias
1. Introducción
El testing es una disciplina muy importante dentro del desarrollo de software. Dentro de esta disciplina tenemos muchas formas de testear nuestra aplicación y una de ellas es contract testing. Tenemos tutoriales interesantes como este donde se explica como hacerlo con pact y springboot o este donde se explica como hacerlo en tecnologías .NET.
Cuando hablamos de contract testing hablamos de formalizar un contrato entre servicios, para que tanto los servicios que proveen datos como los que lo consumen tengan una base sólida sobre la que desarrollarse. En este tutorial veremos como desarrollarlos con spring.
2. Entorno
El tutorial está escrito usando el siguiente entorno:
- Hardware: Portátil Lenovo t480 (1.80 GHz intel i7-8550U, 32GB DDR4)
- Sistema Operativo: Linux Mint 20.3 Una base: Ubuntu 20.04 focal Kernel: 5.15.0-33-generic x86_64 bits
- Entorno de desarrollo: IntelliJ IDEA 2022.1.3 (Ultimate Edition)
- Apache Maven 3.8.4
- Java version: 17.0.3, vendor: Eclipse Adoptium
3. Contract testing
Lo primero seria saber donde encaja todo esto. Si miramos la pirámide de testing:
Vemos que el contract testing estaría entre los tests de integración y los end to end. Y recordemos, la pirámide debe ser una pirámide.
Dentro del contract testing tenemos diferentes variantes según el enfoque:
- Consumer Driven Contract Testing: Donde el consumidor escribe el contrato y le dice al proveedor que lo implemente y valide.
- Provider Driven Contract Testing: Donde el proveedor escribe el contrato y el consumidor prueba su implementación contra ese contrato.
- Design First Contract Testing: Donde el diseñador del API crea un contrato y tanto el consumidor como el proveedor prueban la implementación contra ese contrato.
4. Porque necesitamos contract testing
Hoy en día son bastante comunes las arquitecturas de microservicios, con sus beneficios e inconvenientes.
En cuanto tenemos interacciones entre nuestros sistemas (servicios que dependen unos de otros), deberíamos tener una forma de probar que esas interacciones aunque cambien, no son un problema y, a ser posible, de forma automática. En este tipo de arquitecturas este tipo de testing se vuelve más relevante (o alguna forma similar de asegurarnos que no rompemos interacciones entre servicios). No cabe duda que tenerlo todo bien testeado es algo fundamental.
En este tipo de sistemas, se tienen productores (lo llamaremos ahora así porque spring los denomina producer no provider) y consumidores. Dependiendo del momento un servicio puede tener un rol u otro. El productor da una determinada funcionalidad que es consumida por otros servicios que serian los consumidores. A la hora de desarrollar los sistemas tanto consumidores como productores, es recomendable tener tests de que las integraciones entre los consumidores y los productores son correctas.
Dependiendo del entorno donde estemos, el consumidor y el productor serán desarrollados por equipos diferentes, o, llegado el momento seguramente nos gustaría tener equipos autónomos para desarrollar o modificar los productores y los consumidores con el menor riesgo posible de romper la API. No nos gustaría tocar algo del API, desplegar a producción y que de repente todos nuestros consumidores dejaran de funcionar como esperábamos.
Muchas veces nos ponemos a desarrollar y en medio del desarrollo se nos olvida que estamos en un contexto mucho más grande, pensamos que no vamos a afectar a nadie, pero acabamos haciéndolo.
Para hacer este tipo de tests, tenemos los siguientes enfoques:
- Con dobles de otros servicios y realizando tests de integración.
- Lo despliego todo y hago tests end to end.
Son enfoques distintos y cada uno tiene cabida dependiendo de la situación
4.1. Usando dobles
Lo bueno de usar dobles es que tenemos un feedback muy rápido por qué estamos realizando tests de integración donde las partes que potencialmente son más lentas (las interacciones) están siendo sustituidas por dobles.
Otra ventaja es que los podemos depurar dentro de nuestro entorno de desarrollo.
Por último, si utilizamos dobles, no necesitamos infraestructura para realizar los tests.
Lo malo de usar dobles es que son dobles que hemos hecho nosotros, y puede que no tengamos la confianza de conocer el sistema que estamos simulando con esos dobles. Esto puede desembocar en que el productor cambie el API, pero nosotros no nos enteremos, no cambiemos los dobles y todo se rompa sin que nosotros seamos conscientes de ello porque los tests pasan.
4.2. Usando tests end to end
En este enfoque lo que hacemos es desplegar todo y probar las interacciones.
Como todo es similar a producción, no tenemos el problema de tener los dobles desactualizados y no darnos cuenta de que lo hemos roto, ya que nuestro servicio está interactuado con servicios reales.
Esto tiene algunas cosas que pueden llevarlo a ser impracticable:
- Puede que tener toda una infraestructura en local no sea viable.
- Estos tests son muy lentos y el feedback llega muy tarde.
- Son muy difíciles de depurar, porque implican red (con sus timeouts) y dependencias con otros servicios (delays, servicios que no arrancan).
- No se puede probar hasta que está todo desarrollado, porque lo estamos integrando.
Hay situaciones que hacen esto inviable, como por ejemplo;
Vemos que la cantidad de interacciones entre servicios es enorme, y no podemos levantarlos todos en local.
4.3. Recapitulando los enfoques
Tenemos una aplicación, unos pocos servicios que colaboran entre sí siendo consumidores o productores y tenemos que ver que esos servicios se integran entre sí, que no rompemos el API cuando la modificamos y que el uso que hacemos del API es el correcto. Los dobles tienen sus problemas y desplegar todo también.
Además de todo esto, queremos equipos independientes, sacando versiones de manera independiente para poder evolucionar los servicios de forma paralela. Según hemos visto estas integraciones entre servicios pueden fallar.
Entonces:
- ¿Cómo probar un sistema con múltiples dependencias entre sus partes?
- ¿Cómo permitir que esos tests se ejecuten tan rápido como sea posible?
- ¿Cómo permitir que equipos distintos desplieguen sus servicios con independencia de los clientes (consumidores) de dichos servicios?
- ¿Cómo evitar que cambios en la API rompan a los clientes?
Pues si el título es contract testing, efectivamente, con contract testing.
La idea del contract testing es tener los beneficios de usar dobles (tests rápidos y deterministas, evitando que tests end to end fallen porque un servicio no levante por ejemplo) con un entorno fácil de desplegar(como si fueran unitarios o de integración) y la confianza de usar tests end to end en un entorno real.
El contract testing se basa en usar dobles para representar el contrato acordado entre los servicios consumidor y productor. Este contrato será usado tanto en el consumidor como en el productor. Cunado en el consumidor se hacen tests, en vez de usar el productor real, se utilizan los dobles que se ha generado a partir del contrato.
Además, estos mismos dobles que representan el contrato se pueden utilizar también como un seguro de que el productor está respetando el contrato, es decir, en el productor, esos dobles se pueden utilizar como tests para verificar que el productor se comporta como debe, es decir, acorde al contrato.
Los contratos son la definición de patrones petición/respuesta acordados entre el equipo que hace los consumidores y el equipo que implementa el productor. Estos contratos se utilizan para generar dobles y tests de aceptación del productor. Si lanzamos al productor las peticiones que se especifican en el contrato y el productor devuelve respuestas diferentes a las que están en el contrato, el productor no está cumpliendo el contrato. En el cliente las interacciones con el productor se ejecutan contra un doble que tiene las respuestas predefinidas (el contrato en si).
En contract testing tenemos cuatro partes fundamentales:
- Consumidor: Cualquier servicio que interactúa con otro pidiendo información (El que consume el api)
- Productor: Cualquier servicio que interactúa con otro dando información (El que provee la api)
- Contratos: Un conjunto de interacciones representadas por peticiones y respuestas entre el cliente y productor
- Broker: Un almacén donde guardar los contratos para que puedan ser utilizados como tests
5. Spring cloud contract
El contract testing tiene dos actores, el productor y consumidor. Vamos a verlo desde el punto de vista del consumidor, que suele ser más habitual (Consumer Driven Contract Testing).
Los tests que se hacen en el consumidor, tienen un doble del productor. El contrato se define por separado en el productor y a partir de este contrato el productor genera un doble que se publica con las respuestas definidas en el contrato.
Los tests que se hacen en el productor, simulan el cliente. Las peticiones enviadas son las definidas en el contrato generado, por lo que no hay que inventar ningún test, están ya consensuados entre productor y consumidor
Centrándonos más en spring, tiene un proyecto para ayudar a los desarrolladores a definir contratos y usarlos en los tests de consumidor y productor. Este proyecto se encuentra dentro del proyecto spring cloud, ya que se utiliza mucho con el patrón microservicios en entornos cloud, pero podemos usarlo en cualquier lado como veremos a continuación, simplemente importando las dependencias necesarias. Se está haciendo referencia a apis rest por ser lo mas habitual, pero hay que tener en cuenta que se puede utilizar con apis de mensajería y grpc, es decir, que cualquier sistema que usemos para comunicar servicios es susceptible de que definamos un contrato para que todos los servicios estén alineados.
En concreto, dentro de spring cloud el subproyecto es spring cloud contract Spring contract cloud nos va a proporcionar un DSL para definir los contratos en groovy, YAML, java o kotlin.
A partir de estos contratos generados por el productor (en el dsl elegido) se genera:
- Un doble que puede usarse para simular el productor en los tests del cliente. El doble devuelve las respuestas configuradas en el contrato.
- Los tests de aceptación para el productor a partir del contrato.
- El doble se publica en algún repositorio (maven, github, pact) que actúa como broker.
En el consumidor:
- El test se implementa manualmente.
- Se referencia el doble según el broker que tengamos para simular el productor.
6. Ejemplo práctico
6.1. productor
Los contratos pueden estar escritos en groovy, java, yml y kotlin. En nuestro caso los vamos a implementar en groovy. Si optamos por kotlin hay añadir más dependencias. Vamos a tener dos contratos que interactúan con el cuerpo humano, uno que nos dice como está el corazón, y otro que nos pone un corazón nuevo.
Este contrato nos da el corazón. Es una llamada get a /body donde le pasamos un query param que es part donde se le indica que es el corazón (heart) y este nos va a devolver que el status de ese corazón es on.
package contracts import org.springframework.cloud.contract.spec.Contract Contract.make { name = "Get heart" description = "Get heart status" priority = 2 ignored = false request { urlPath('/body') { queryParameters { parameter 'part': 'heart' } } method GET() } response { status OK() body( status: "on", ) } }
Luego tenemos este otro contrato que es un put al mismo path (/body) que tiene un body donde le indicamos que le queremos poner y de que tipo será. Esta llamada nos da un status code de 200 con un body que nos da un mensaje de success y en los headers el sitio donde esta esa parte del cuerpo.
package contracts import org.springframework.cloud.contract.spec.Contract Contract.make { name = "Put new heart" description = "Patient will get a new heart" priority = 1 ignored = false request { urlPath('/body') method PUT() headers { contentType applicationJson() } body( part: "heart", kind: "A good one", ) } response { status OK() body( success: "Ou yeah", ) headers { contentLocation: "Chest" } } }
Vale, ya tenemos dos contratos en el productor, aparte de eso necesitamos ponerle las siguientes dependencias.
<dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-contract-verifier</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> </dependencies>
Para que todo funcione, necesitamos el plugin de spring-cloud-contract-maven-plugin donde le vamos a decir que usamos Junit 5 y la clase base que vamos a usar para generar los dobles.
<build> <plugins> <plugin> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-contract-maven-plugin</artifactId> <version>${spring-cloud-contract-maven-plugin.version}</version> <extensions>true</extensions> <configuration> <testFramework>JUNIT5</testFramework> <baseClassForTests>es.dionisiocortes.contractTesting.producer.BaseClass</baseClassForTests> </configuration> </plugin> </plugins> </build>
Por si resulta raro, aquí estamos solo usandolo, ya que el proyecto padre es el que las declara al estar en un proyecto multimodulo. En el padre están así:
<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>${spring-cloud.version}</version> <type>pom</type> <scope>import</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <type>pom</type> <version>${spring-boot.version}</version> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>
Si nos hemos fijado cuando declaramos el plugin, tenemos una clase es.dionisiocortes.contractTesting.producer.BaseClass que lo que nos hace es definir los tests que se van a generar de forma automática para el contrato. Esa clase es la siguiente:
package es.dionisiocortes.contractTesting.producer; import io.restassured.module.mockmvc.RestAssuredMockMvc; import org.junit.jupiter.api.BeforeEach; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest() public abstract class BaseClass { @Autowired BodyController bodyController; @BeforeEach public void setup() { RestAssuredMockMvc.standaloneSetup(bodyController); } }
El controlador no tiene nada especial, es simplemente un controlador de Spring que tiene el método get para cuando se invoca el endpoint para el get y luego el endpoint para hacer el put:
package es.dionisiocortes.contractTesting.producer; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.Map; import static org.springframework.http.HttpStatus.OK; @RestController @RequestMapping("/body") public class BodyController { @GetMapping() @ResponseStatus(OK) public Map getPart(@RequestParam String part) { if (part.equals("heart")) { return Map.of("status", "on"); } return Map.of("status", "off"); } @PutMapping() @ResponseStatus(OK) public ResponseEntity<Map> putHeart(@RequestBody PutRequest request) { HttpHeaders responseHeaders = new HttpHeaders(); responseHeaders.set("Content-Location", "Chest"); var response = ResponseEntity.ok().headers(responseHeaders); if (request.part().equals("heart")) { return response.body(Map.of("success", "Ou yeah")); } return response.body(Map.of("status", "failed")); } }
Y luego la request es un record:
public record PutRequest(String part, String kind) { }
Si ejecutamos
mvn clean install
Veremos que ha generado e instalado en el repositorio .m2 el contrato y que además ha ejecutado y pasado dos tests:
[INFO] --- spring-cloud-contract-maven-plugin:3.1.3:generateTests (default-generateTests) @ producer --- [INFO] Generating server tests source code for Spring Cloud Contract Verifier contract verification [INFO] Will use contracts provided in the folder [/home/dio/dev/springContractTesting/producer/src/test/resources/contracts] [INFO] Directory with contract is present at [/home/dio/dev/springContractTesting/producer/src/test/resources/contracts] [INFO] Test Source directory: /home/dio/dev/springContractTesting/producer/target/generated-test-sources/contracts added. [INFO] Using [es.dionisiocortes.contractTesting.producer.BaseClass] as base class for test classes, [null] as base package for tests, [null] as package with base classes, base class mappings [] [INFO] Creating new class file [/home/dio/dev/springContractTesting/producer/target/generated-test-sources/contracts/es/dionisiocortes/contractTesting/producer/ContractVerifierTest.java] [INFO] Generated 1 test classes. [INFO] Converting from Spring Cloud Contract Verifier contracts to WireMock stubs mappings [INFO] Spring Cloud Contract Verifier contracts directory: /home/dio/dev/springContractTesting/producer/src/test/resources/contracts [INFO] Stub Server stubs mappings directory: /home/dio/dev/springContractTesting/producer/target/stubs/META-INF/es.dionisiocortes/producer/0.0.1-SNAPSHOT/mappings [INFO] Creating new stub [/home/dio/dev/springContractTesting/producer/target/stubs/META-INF/es.dionisiocortes/producer/0.0.1-SNAPSHOT/mappings/Get heart.json] [INFO] Creating new stub [/home/dio/dev/springContractTesting/producer/target/stubs/META-INF/es.dionisiocortes/producer/0.0.1-SNAPSHOT/mappings/Put new heart.json] [INFO] --- maven-install-plugin:2.5.2:install (default-install) @ producer --- [INFO] Installing /home/dio/dev/springContractTesting/producer/target/producer-0.0.1-SNAPSHOT.jar to /home/dio/.m2/repository/es/dionisiocortes/producer/0.0.1-SNAPSHOT/producer-0.0.1-SNAPSHOT.jar [INFO] Installing /home/dio/dev/springContractTesting/producer/pom.xml to /home/dio/.m2/repository/es/dionisiocortes/producer/0.0.1-SNAPSHOT/producer-0.0.1-SNAPSHOT.pom [INFO] Installing /home/dio/dev/springContractTesting/producer/target/producer-0.0.1-SNAPSHOT-stubs.jar to /home/dio/.m2/repository/es/dionisiocortes/producer/0.0.1-SNAPSHOT/producer-0.0.1-SNAPSHOT-stubs.jar ... [INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.535 s - in es.dionisiocortes.contractTesting.producer.ContractsTest
6.2. Consumidor
En el consumidor necesitamos dos dependencias que son el spring-boot-test y el spring-cloud-starter-contract-stub-runner:
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-contract-stub-runner</artifactId> <scope>test</scope> </dependency> </dependencies>
El equipo que desarrolla el consumidor puede hacer el desarrollo del controlador que hace uso del servicio externo, por ejemplo con RestTemplate así:
package es.dionisiocortes.contractTesting.consumer; import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.client.RestTemplate; import java.util.HashMap; import java.util.Map; import static org.springframework.http.HttpStatus.OK; @RestController @RequestMapping("/cardio") public class CardiologistController { private final RestTemplate restTemplate; public CardiologistController(RestTemplateBuilder restTemplateBuilder) { restTemplate = restTemplateBuilder.build(); } @GetMapping("/status") @ResponseStatus(OK) String getHeartStatus() { return restTemplate.getForObject("http://localhost:8080/body?part=heart", String.class); } @PutMapping("/fix") @ResponseStatus(OK) ResponseEntity PutHeart(@RequestBody PutRequest request) { HttpHeaders headers = new HttpHeaders(); Map param = new HashMap(); param.put("part", "heart"); param.put("kind", request.kind()); HttpEntity<Map> requestEntity = new HttpEntity(param, headers); return restTemplate.exchange("http://localhost:8080/body", HttpMethod.PUT, requestEntity, String.class); } }
Para la request volvemos a usar un record:
public record PutRequest(String kind) { }
Y su test correspondiente. En este test vemos que le decimos que el webEnvironment va a ser un mock, que nos los configure automáticamente, que el doble que nos ha generado está en local y le indicamos unas coordenadas maven y un puerto para indicar que doble es el que tiene que usar.
package es.dionisiocortes.contractTesting.consumer; 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.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import static org.springframework.cloud.contract.stubrunner.spring.StubRunnerProperties.StubsMode.LOCAL; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) @AutoConfigureMockMvc @AutoConfigureStubRunner(ids = {"es.dionisiocortes:producer:+:stubs:8080"}, stubsMode = LOCAL) public class ConsumerTest { @Autowired private MockMvc mockMvc; @Test void verify_get_heart() throws Exception { mockMvc.perform(MockMvcRequestBuilders.get("/cardio/status") .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(content().json("{\"status\":\"on\"}")); } @Test void verify_put_heart() throws Exception { mockMvc.perform(MockMvcRequestBuilders.put("/cardio/fix") .contentType(MediaType.APPLICATION_JSON) .content("{\"kind\":\"A good one\"}")) .andExpect(status().isOk()) .andExpect(content().json("{\"success\":\"Ou yeah\"}")); } }
7. Usando git como broker
Si estamos trabajando con tecnologías java, es bastante habitual tener un repositorio de artefactos maven. Esta opcion es la que nos da por defecto, pero si tener un repositorio maven es algo habitual, más habitual es tener un repositorio git, así que vamos a comentar esta alternativa aquí. Lo primero que necesitamos en un repositorio de git vacío. Cuando generamos los contratos, dentro de la carpeta target/stubs del productor tenemos la carpeta META-INF. Esa carpeta META-INF hay que subirla al repositorio. Una vez que tenemos el repositorio configurado, tenemos que decirle al productor que tiene que mandar esos contratos al repositorio.
El plugin como vemos a continuación, tenemos que configurarle para decirle:
- Donde está el repositorio: contractsRepositoryUrl.
- En que rama: git.branch.
- Coordenadas del contrato en: contractDependency.
- Modo remoto (REMOTE) en: contractsMode.
- Goal de maven para mandarlo al repositorio: pushStubsToScm.
<plugin> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-contract-maven-plugin</artifactId> <version>${spring-cloud-contract-maven-plugin.version}</version> <extensions>true</extensions> <configuration> <testFramework>JUNIT5</testFramework> <baseClassForTests>es.dionisiocortes.contractTesting.producer.BaseClass</baseClassForTests> <contractsRepositoryUrl>git://git@github.com:dionisioC/spring-cloud-contract-contracts-git.git </contractsRepositoryUrl> <contractsProperties> <git.branch>main</git.branch> </contractsProperties> <contractDependency> <groupId>${project.groupId}</groupId> <artifactId>${project.artifactId}</artifactId> <version>${project.version}</version> </contractDependency> <contractsMode>LOCAL</contractsMode> <contractsMode>REMOTE</contractsMode> </configuration> <executions> <execution> <phase>package</phase> <goals> <goal>pushStubsToScm</goal> </goals> </execution> </executions> </plugin>
Si ejecutamos mvn clean install, ahora veremos que se está clonando el repositorio y esta actualizándolo:
[INFO] Generating server tests source code for Spring Cloud Contract Verifier contract verification [INFO] Download dependency is provided - will retrieve contracts from a remote location [INFO] Cloning repo from [git:git@github.com:dionisioC/spring-cloud-contract-contracts-git.git] to [/tmp/git-contracts-1658223465702-0] [INFO] Successfully connected to an agent [INFO] Cloned repo to [/tmp/git-contracts-1658223465702-0] [INFO] Won't check out the same branch. Skipping [INFO] Will pick a pattern from group id and artifact id [INFO] Pattern to pick contracts equals [^/tmp/git-contracts-1658223465702-0/META-INF/es.dionisiocortes/producer/0.0.1-SNAPSHOT/contracts.*$] [INFO] Ant Pattern to pick files equals [**/] [INFO] Directory with contract is present at [/tmp/git-contracts-1658223465702-0/META-INF/es.dionisiocortes/producer/0.0.1-SNAPSHOT] [INFO] Test Source directory: /home/dio/dev/springContractTesting/producer/target/generated-test-sources/contracts added. [INFO] Using [es.dionisiocortes.contractTesting.producer.BaseClass] as base class for test classes, [null] as base package for tests, [null] as package with base classes, base class mappings [] [INFO] Creating new class file [/home/dio/dev/springContractTesting/producer/target/generated-test-sources/contracts/es/dionisiocortes/contractTesting/producer/ContractsTest.java] [INFO] Generated 1 test classes. [INFO] [INFO] --- spring-cloud-contract-maven-plugin:3.1.3:convert (default-convert) @ producer --- [INFO] Another mojo has downloaded the contracts - will reuse them from [/tmp/git-contracts-1658223465702-0/META-INF/es.dionisiocortes/producer/0.0.1-SNAPSHOT] [INFO] Will pick a pattern from group id and artifact id [INFO] Pattern to pick contracts equals [^/tmp/git-contracts-1658223465702-0/META-INF/es.dionisiocortes/producer/0.0.1-SNAPSHOT/contracts.*$] [INFO] Ant Pattern to pick files equals [**/] [INFO] Copying Spring Cloud Contract Verifier contracts to [/home/dio/dev/springContractTesting/producer/target/stubs/META-INF/es.dionisiocortes/producer/0.0.1-SNAPSHOT/contracts]. Only files matching [^/tmp/git-contracts-1658223465702-0/META-INF/es.dionisiocortes/producer/0.0.1-SNAPSHOT/contracts.*$] pattern will end up in the final JAR with stubs. [INFO] Using 'UTF-8' encoding to copy filtered resources. [INFO] Copying 2 resources [INFO] Converting from Spring Cloud Contract Verifier contracts to WireMock stubs mappings [INFO] Spring Cloud Contract Verifier contracts directory: /tmp/git-contracts-1658223465702-0/META-INF/es.dionisiocortes/producer/0.0.1-SNAPSHOT/contracts [INFO] Stub Server stubs mappings directory: /home/dio/dev/springContractTesting/producer/target/stubs/META-INF/es.dionisiocortes/producer/0.0.1-SNAPSHOT/mappings [INFO] Creating new stub [/home/dio/dev/springContractTesting/producer/target/stubs/META-INF/es.dionisiocortes/producer/0.0.1-SNAPSHOT/mappings/getHeart.json] [INFO] Creating new stub [/home/dio/dev/springContractTesting/producer/target/stubs/META-INF/es.dionisiocortes/producer/0.0.1-SNAPSHOT/mappings/putNewHeart.json] ... [INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.535 s - in es.dionisiocortes.contractTesting.producer.ContractsTest ... [INFO] --- spring-cloud-contract-maven-plugin:3.1.3:pushStubsToScm (default) @ producer --- [INFO] Pushing Stubs to SCM for project [es.dionisiocortes:producer:0.0.1-SNAPSHOT] [INFO] Successfully connected to an agent [INFO] Commited successfully with message [Updating project [es.dionisiocortes:producer:0.0.1-SNAPSHOT] with stubs] [INFO] Trying to push changes, attempt 1/10 [INFO] Successfully connected to an agent [INFO] Successfully pulled changes from remote for project with contract and stubs [INFO] Successfully pushed changes with current stubs
Ahora que lo tenemos en el repositorio, necesitamos decirle a los tests del consumidor donde está el stub nuevo, así que el test nos quedaría así:
package es.dionisiocortes.contractTesting.consumer; 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.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import static org.springframework.cloud.contract.stubrunner.spring.StubRunnerProperties.StubsMode.REMOTE; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) @AutoConfigureMockMvc @AutoConfigureStubRunner(ids = {"es.dionisiocortes:producer:+:stubs:8080"}, stubsMode = REMOTE, repositoryRoot = "git://git@github.com:dionisioC/spring-cloud-contract-contracts-git.git", properties = "git.branch=main") public class ConsumerGitTest { @Autowired private MockMvc mockMvc; @Test void verify_get_heart() throws Exception { mockMvc.perform(MockMvcRequestBuilders.get("/cardio/status") .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(content().json("{\"status\":\"on\"}")); } @Test void verify_put_heart() throws Exception { mockMvc.perform(MockMvcRequestBuilders.put("/cardio/fix") .contentType(MediaType.APPLICATION_JSON) .content("{\"kind\":\"A good one\"}")) .andExpect(status().isOk()) .andExpect(content().json("{\"success\":\"Ou yeah\"}")); } }
Podemos observar que las diferencias están en el modo, que ahora es remoto, la URL del repositorio y no solo el artefacto maven, y en que rama esta.
8. Conclusiones
Contract testing es una herramienta más que debemos tener en nuestra caja de herramientas, y, que si se presenta la ocasión cuando estemos desarrollando una API que van a necesitar otros equipos, deberíamos de usar para asegurarnos que cuando se cambie la versión del API no rompa nada, y que los consumidores siempre están alineados con los productores. En este tutorial hemos explorado los contratos para una API rest, pero tambien se puede usar con otras tecnologías como mensajería y grpc. Este artículo se centra en spring, pero recordemos que hay alternativas como por ejemplo pact