La arquitectura de microservicios nos da la posibilidad de escalar nuestros servicios de forma óptima y también agilizar el desarrollo de nuevas funcionalidades, pero crea otros obstáculos que nos complican la vida. Uno de ellos es el testing.

Los microservicios se comunican con otros microservicios a través de algún protocolo, el más famoso es API Rest y, para poder reducir el Time to Market, es importante paralelizar su desarrollo. Cada microservicio puede ser creado por un equipo diferente y el objetivo es garantizar que ellos se entienden bien y entreguen un producto que funcione a la perfección sin hacer muchos ajustes. Si hacemos una analogía con una línea de ensamblaje, diferentes equipos fabrican piezas de un producto en paralelo. Cada equipo sigue especificaciones precisas para asegurar que las piezas sean compatibles y puedan ensamblarse correctamente sin necesidad de ajustes adicionales.

¿Cómo podemos probar la lógica de negocio de cada uno de los microservicios y garantizar que nada ha cambiado en la comunicación entre ellos?

Entorno controlado

Lo primero que se nos viene a la cabeza es tener un entorno de desarrollo controlado para probar nuestro microservicio (SUD).

system under test --> mock server --> service 1 / service 2 / service 3

Yo estaba trabajando en un equipo que consumía diferentes servicios de un eCommerce y consumíamos muchos servicios de diferentes dominios. En este caso utilizamos mock server para emular el comportamiento de las APIs de los otros dominios para comprobar si cumplía con los criterios de aceptación. Esto nos daba confianza y lo podíamos usar también en CI/CD.

Era demasiado bueno para que fuese verdad. Hablamos de un eCommerce que usaría un servicio que devolverá el listado de los productos. Definimos el contrato, lo publicamos en el Mock Server y empezamos el desarrollo.

"products": { "productId": "17035535", "productName": "Cheap product", "price": "9.99 EUR" { "productId": "17005954", "productName": "Quality product" "price": "29.99 EUR"

Mientras estábamos desarrollando, nos dimos cuenta de que el productId en la lista de productos era redundante y podía simplificarse a id. Lo mismo ocurría con productName, que podía llamarse simplemente name.

Hablamos con el equipo que estaba desarrollando el servicio y acordamos hacer los cambios:

Se aplican las optimizaciones al código, sustituyendo "productId" por "id" y "productName" por "name"

Generamos el contrato y actualizamos los stub servers.

Unos meses después, se decidió la expansión a otros mercados donde la divisa es distinta al euro, por lo que el campo price, que era cadena de texto, se separó en amount y currency. Era evidente que esto ocurriría, pero priorizamos lanzar el producto lo antes posible, asumiendo este trade-off desde el inicio.

en el código anterior, teníamos el campo "price: 9.99 EUR". En el código actualizado para cubrir mercados con divisa distinta al euro, modificamos el campo "price" a: "price": { "amount": "9.99" "currency": "EUR"} y mantenemos el resto de campos igual

Igual que antes, generamos el contrato y actualizamos los stub servers. Teníamos muchos trade-off y este approach nos estaba demorando mucho. Necesitábamos algo que nos facilite esto y lo haga de una forma automatizada. Buscando una solución conocí Consumer-Driven Contract Testing.

Consumer-Driven Contract Testing

Consumer-Driven Contract Testing es un tipo de prueba que garantiza que un proveedor cumpla con las expectativas del consumidor. Para una API HTTP (y otros protocolos síncronos), esto implica comprobar que el proveedor acepta las solicitudes esperadas y devuelve las respuestas esperadas. Para un sistema que utiliza colas de mensajes, implica comprobar que el proveedor genera el mensaje esperado.

Hay diferentes herramientas para Contract Testing como Pact, Spring Cloud Contract, etc.

En este artículo usaremos Spring Cloud Contract para crear producer y consumer con Spring Boot 3, definir un contrato entre ellos e implementar nuevas funcionalidades garantizando que cada uno cumpla su parte del contrato.

Terminología

  1. Producer. Este es el microservicio que expone una API o interfaz consumida por otros servicios. La responsabilidad de proporcionar datos o funcionalidades a los consumidores es del proveedor. En nuestro caso sería el dominio del catálogo que proporciona el servicio (catalog).
  2. Consumer. Este es el microservicio que consume la API o interfaz proporcionada por el producer. En este caso sería el dominio del core de nuestra tienda electrónica (shop).
  3. Contract. Es un acuerdo entre el consumer y el producer sobre cómo debe estructurarse la comunicación entre ellos. Define:

El contrato es generado por el consumer y validado contra el producer para asegurarse de que los cambios en la API no rompan su compatibilidad.

Spring Cloud Contract

Spring Cloud Contract es un proyecto que proporciona soluciones para implementar con éxito el enfoque de Consumer-Driven Contract Testing. Actualmente, forma parte de Spring Cloud Contract Verifier, una herramienta diseñada para facilitar la verificación de contratos en aplicaciones basadas en la JVM.

Spring Cloud Contract Verifier permite desarrollar aplicaciones basadas en contratos definidos por los consumidores. Ofrece un Lenguaje de Definición de Contratos (DSL), que puede escribirse en Groovy o YAML. A partir de estas definiciones, la herramienta genera automáticamente diversos recursos para garantizar la compatibilidad entre servicios:

Spring Cloud Contract Verifier lleva TDD al nivel de arquitectura de software.

Estructura spring cloud contract verifier

Beneficios de Spring Cloud Contract

Desde el lado del producer:

Desde el lado del consumer:

Ciclo de trabajo

Cuando estamos trabajando con Consumer-Driven Contract Testing, tenemos tareas definidas para realizar el trabajo completo.

  1. Definición del contrato: el primer paso es que los dos equipos se reúnan y definan el contrato. El equipo consumidor define sus requisitos y entre los dos equipos definen un contrato.
  2. Generación de stubs y publicación: el equipo proveedor generará los stubs con su proyecto a base del contrato definido en el paso 1 y publicará en un repositorio (Artifactory o Maven), de donde el equipo consumidor puede acceder a ellos.
  3. Desarrollo en paralelo: los dos equipos empiezan a trabajar en paralelo con la implementación de la logística de negocio.

¿Qué pasa si uno de los equipos necesita cambiar el contrato?

Levanta la mano 🙋 y pide una reunión con el otro equipo para refinar el contrato (como en el paso 1), pero se hacen modificaciones en la última versión del contrato y se sigue con los pasos 2 y 3.

Manos a la obra: caso práctico

Vamos a crear un proyecto multimódulo de Maven que tendría 2 módulos:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
 xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
 <modelVersion>4.0.0</modelVersion>

 <groupId>com.paradigmadigital</groupId>
 <artifactId>spring-boot-3-consumer-driven-contract-testing</artifactId>
 <version>1.0-SNAPSHOT</version>
 <packaging>pom</packaging>
 <modules>
   <module>catalog</module>
   <module>shop</module>
 </modules>

 <properties>
   <maven.compiler.source>21</maven.compiler.source>
   <maven.compiler.target>21</maven.compiler.target>
   <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
 </properties>

</project>

Crear el producer

Vamos a la página favorita de Josh Long: Spring initializr y generamos el producer incluyendo las siguientes dependencias:

página de configuración de spring initializr incluyendo Spring Web Contract Verifier Spring Data JPA H2 Database Lombok

Generamos el proyecto y extraemos su contenido en el módulo catalog.

Crear los contratos

En la ruta catalog/src/test/resources/ tendremos una carpeta contracts. Si no existe la creamos, porque en ella vamos a añadir los contratos. Podemos escribirlos en Java o utilizar alguno de los DSL. Spring Cloud Contract ofrece dos tipos de DSL: uno escrito en Groovy y otro en YAML. Vamos a ver cómo se escriben los contratos en cada uno de los formatos soportados.

El contrato será del ejemplo que vimos antes, si se hace una petición GET al endpoint /products se devolverá la lista de los productos definidos en el fichero products.json:

{
 "products": [
   {
     "productId": "17035535",
     "productName": "Cheap product",
     "price": "9.99 EUR"
   },
   {
     "productId": "17005954",
     "productName": "Quality product",
     "price": "29.99 EUR"
   }
 ]
}

Definición del contrato en Java:

public class shouldReturnProducts implements Supplier<Collection<Contract>> {

   @Override
   public Collection<Contract> get() {
       return Collections.singletonList(Contract.make(contract -> {
           contract.description("Should return products list");
           contract.name("Java contract");

           contract.request(request -> {
               request.url("/products");
               request.method(request.GET());
           });

           contract.response(response -> {
               response.status(response.OK());
               response.headers(header -> {
                   header.contentType(header.applicationJson());
               });
               response.body(response.file("response/products.json"));
           });
       }));
   }

}

Definición del contrato en Groovy:

import org.springframework.cloud.contract.spec.Contract

Contract.make {
   description "Should return products list"
   name "Groovy contract"

   request {
       method GET()
       url '/products'
   }

   response {
       status OK()
       headers {
           contentType(applicationJson())
       }
       body(file("response/products.json"))
   }
}

Definición del contrato en YAML:

description: yaml contract
request:
   method: GET
   url: /products
response:
   status: 200
   headers:
       Content-Type: application/json
   bodyFromFile: response/products.json

Los 3 contratos generan el mismo código Java:

@Test
public void validate_shouldReturnProducts() throws Exception {
   // given:
      MockMvcRequestSpecification request = given();


   // when:
      ResponseOptions response = given().spec(request)

            .get("/products");

   // then:
      assertThat(response.statusCode()).isEqualTo(200);
      assertThat(response.header("Content-Type")).isEqualTo("application/json");


   // and:
      DocumentContext parsedJson = JsonPath.parse(response.getBody().asString());
      assertThatJson(parsedJson).array("['products']").contains("['productId']").isEqualTo("17035535");
      assertThatJson(parsedJson).array("['products']").contains("['productName']").isEqualTo("Cheap product");
      assertThatJson(parsedJson).array("['products']").contains("['price']").isEqualTo("9.99 EUR");
      assertThatJson(parsedJson).array("['products']").contains("['productId']").isEqualTo("17005954");
      assertThatJson(parsedJson).array("['products']").contains("['productName']").isEqualTo("Quality product");
      assertThatJson(parsedJson).array("['products']").contains("['price']").isEqualTo("29.99 EUR");
}

Vemos que tenemos tests generados automáticamente dependiendo del contrato. Es impresionante.

Configurar las pruebas de contrato

Nos falta configurar la clase principal para que los tests de contrato se puedan ejecutar correctamente. En nuestro caso tendríamos:

@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@Sql({"/sql/schema_test.sql", "/sql/test_products.sql"})
public class BaseTestClass {

   @Autowired
   private ProductController productController;

   @BeforeEach
   public void setup() {
       StandaloneMockMvcBuilder standaloneMockMvcBuilder = MockMvcBuilders.standaloneSetup(productController);
       RestAssuredMockMvc.standaloneSetup(standaloneMockMvcBuilder);
   }
}

Una vez creado el BaseTestClass.java, debemos configurarlo para que pueda funcionar con el plugin de Maven para Spring Cloud Contract.

<plugin>
 <groupId>org.springframework.cloud</groupId>
 <artifactId>spring-cloud-contract-maven-plugin</artifactId>
 <version>4.2.1</version>
 <extensions>true</extensions>
 <configuration>
  <testFramework>JUNIT5</testFramework>
  <baseClassForTests>com.paradigmadigital.catalog.BaseTestClass</baseClassForTests>
 </configuration>
</plugin>

Generar los stubs

Con esto ya podemos generar los stubs sin que hayamos implementado la lógica de negocio con el siguiente comando:

mvn clean install -pl catalog

Lo ideal sería que los stubs se publiquen en el Artifactory como parte del pipeline de CI/CD. De esta manera, los dos equipos pueden trabajar en paralelo.

Crear el consumer

Volvemos de nuevo a Spring initializr generamos el consumer incluyendo los siguientes dependencias:

página de configuración de spring initializr incluyendo Spring Web Contract Stub Runner Lombok

Generamos el proyecto y extraemos su contenido en el módulo shop.

Tests de integración para el consumidor

Los tests de integración del consumidor utilizan los stubs generados por el equipo del productor. Esta es la manera simple de un tests de integración:

@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@AutoConfigureStubRunner(stubsMode = StubRunnerProperties.StubsMode.LOCAL,
   ids = "com.paradigmadigital:catalog:+:stubs:8080")
class ShopControllerIntegrationTest {

   @Autowired
   private ShopController shopController;

   @Test
   void testContractToCatalogProducer() {
       var result = shopController.getProducts().getBody();

       assertNotNull(result);

       assertThat(result.products()).extracting("id")
           .contains(17035535L, 17005954L);
   }
}

Con la anotación AutoConfigureStubRunner configuramos:

Cambio del contrato

El equipo encargado del consumidor (Shop Team) casi ha terminado la implementación, pero se ha detectado que se podía mejorar el contrato. Se ha eliminado la redundancia:

pantallazo donde se muestran los cambios para la optimización del código (id / name)

Han acordado estos cambios con el equipo que está encargado de producer (Catalog Team), han modificado el contrato y han publicado nueva versión de los stubs. Y los dos equipos siguen el desarrollo.

El proceso de trabajo es idéntico cuando el campo price, que era cadena de texto, se separó en amount y currency.

código donde se muestran los cambios que se hicieron en "price": amount y currency

Conclusiones

Hemos visto cómo Spring Cloud Contract facilita la gestión de contratos entre un consumidor y un proveedor de servicios, permitiendo implementar nuevos cambios sin temor a romper la integración.

Gracias a Spring Cloud Contract Verifier y Stub Runner, obtenemos una retroalimentación rápida sin necesidad de desplegar todo el ecosistema de microservicios. Esto garantiza que los stubs utilizados en el desarrollo del cliente reflejan con precisión el comportamiento real del servidor.

Podéis ver todo el código con los commits por pasos en el repositorio GitHub: Example of Consumer Driven Contract Testing with Spring Boot 3.

Cuéntanos qué te parece.

Los comentarios serán moderados. Serán visibles si aportan un argumento constructivo. Si no estás de acuerdo con algún punto, por favor, muestra tus opiniones de manera educada.

Suscríbete