En artículos anteriores hablamos de cómo el patrón Consumer-driven Contracts nos proporciona un marco de trabajo para el desarrollo de acuerdos de interfaz de forma que nos permite un modelo de trabajo ágil, independiente y mantenible.

También analizamos, a alto nivel, cómo Spring Cloud Contract nos ofrece las herramientas necesarias para implementar dicho marco de trabajo.

En este post iremos un paso más allá y recorreremos todo el proceso: desde la generación del contrato hasta su uso en consumidor y productor, detallando los comandos y el código utilizado para implementar un acuerdo de interfaz con Spring Cloud Contract. ¡Empecemos!

Con la finalidad de mantener la sencillez y hacer el artículo lo más comprensible posible, intentaremos reducir el guión a su mínima expresión manteniendo, eso sí, todas las etapas clave.

Así mismo, asumiremos en este caso un único consumidor con una única interfaz a consumir. Como dijimos en el anterior post, algunas partes de este proceso se pueden producir en paralelo. El proceso constará de las siguientes etapas:

  1. Desarrollo de los tests de integración en el consumidor.
  2. Definición del contrato por parte del consumidor.
  3. Publicación del contrato.
  4. Configuración de los stubs en el consumidor.
  5. Validación del contrato en el productor.

El caso de uso que da lugar a esta comunicación es el siguiente: tenemos un Microservicio account-manager que se encarga de la administración de las cuentas de usuario, como parte de este proceso dicho MS puede crear una cuenta nueva.

El proceso de creación de nueva cuenta lleva asociada la creación de una serie de recursos adicionales como son el cliente, sus credenciales, sus permisos, su configuración…

La administración del cliente está gestionada por el Microservicio customer. Así, durante el proceso de creación de cuenta, será necesario crear también el recurso cliente.

Este será el contrato que utilizaremos como ejemplo, el que permite la creación de un cliente. Por tanto, el MS customer será el productor o servidor y el MS account-manager será el consumidor.

Desarrollo de tests de integración en el lado consumidor

El proceso comienza derivado de una necesidad de negocio que es la administración de cuentas de usuario, será por tanto el MS account-manager el responsable de dicha funcionalidad. Será account-manager, el consumidor a quien primero se trasladará la necesidad de negocio.

Como comentamos, CDC está fuertemente ligado a TDD y como parte de esta filosofía primero implementaremos el test de integración que prueba la nueva funcionalidad. En este caso la funcionalidad será invocada en el endpoint /account a través de la operación POST proporcionando la información de la cuenta a crear.

Como resultado del proceso, se debe retornar un código HTTP 200 y la información de la cuenta creada en el cuerpo del mensaje. A continuación podemos ver el test de integración:


@RunWith(SpringRunner.class)
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT,
 classes = AccountManagerApplicationTest.class)
@AutoConfigureMockMvc
@ActiveProfiles("test")
public class AccountControllerTest {
 @Autowired
 private MockMvc mockMvc;
 @Test
 public void shouldCreateAccount() throws Exception {
 String body = "{\"name\":\"Manuel\", \"username\":\"manuel32\", \"email\":\"manuel@gmail.com\", "
 + "\"password\":\"m45u23\", \"age\":32}";
 mockMvc.perform(
 post("/account")
 .contentType(APPLICATION_JSON_UTF8_VALUE)
 .content(body))
 .andExpect(MockMvcResultMatchers.status().is2xxSuccessful())
 .andExpect(content().contentType(APPLICATION_JSON_UTF8_VALUE))
 .andExpect(jsonPath("name", is("Manuel")))
 .andExpect(jsonPath("age", is(32)))
 .andExpect(jsonPath("email", is("manuel@gmail.com")));
 }
}

Si observamos detenidamente no estamos definiendo comportamiento para ningún mock del MS customer, solo estamos definiendo la petición que se realizará y validando la respuesta.

Por supuesto el test fallará, ya que todavía no hemos desarrollado el código correspondiente a esta funcionalidad. Es necesario remarcar la simpleza del test, ya que él mismo solo valida que ante una entrada se produzca una determinada salida. Un test más completo incluiría validación de las interacciones que durante el proceso se realizan, elementos que se fueran a persistir...

El siguiente paso será, por tanto, implementar el código que cumplirá nuestro test. En este punto no podemos llegar al funcionamiento del test, ya que cuando realicemos la llamada al MS customer (http://localhost:8080) no tendremos ninguna instancia levantada, ni ningún stub listo que nos responda para que la ejecución del test sea verde.

A continuación podemos ver en la traza de error que el fallo en el test se debe a que no es capaz de localizar una instancia del MS customer:

En este punto es donde requerimos de la nueva funcionalidad del MS customer. Para ello redactaremos un contrato/acuerdo de interfaz que defina cómo realizar la comunicación cuando sea necesario crear un nuevo cliente.

Definición del contrato por parte del consumidor

Inciso: en este caso nosotros somos los que operamos tanto el MS customer como account-manager, por lo que pasaremos directamente a trabajar sobre customer. Pero en la situación más habitual, donde cada MS fuese operado por un equipo, será necesario que nos abramos una rama sobre el repositorio (ya sea un repositorio exclusivamente de contratos o el repositorio de código del MS customer) donde definiremos el contrato.

Tras acabar la definición, crearemos una pull-request al equipo que administra el MS customer para que revisen el contrato definido. En este punto comenzará un proceso iterativo de comentarios, revisiones y cambios hasta alcanzar un acuerdo que satisfaga a ambas partes (sin que esto sea obstáculo para futuros cambios en el contrato). En el artículo obviaremos este proceso para centrarnos únicamente en lo referente a SCC.

A continuación podemos ver el contrato definido:

groovy

import org.springframework.cloud.contract.spec.Contract
[
 Contract.make {
 name("should create a customer")
 // You can give a description of the user case that the contract represents
 description('''
 given:
 A request to create an user
 when:
 The user is stored in the database
 then:
 The created user is returned
 ''')
 request {
 method 'POST'
 url '/customer'
 body(
 //If we provide only the side of the regular expression, the other one will
 //be generated according to the provided expression
 name : $(c(regex(nonBlank())), p('Antonio')),
 email : $(c(regex(email())), p('antonio@gmail.com')),
 age : $(c(regex(number())), p(42))
 )
 headers {
 contentType('application/json')
 }
 }
 response {
 status 201
 body (
 //We reference values from the request
 name: $(p(fromRequest().body('name'))),
 email: $(p(fromRequest().body('email'))),
 age: $(p(fromRequest().body('age')))
 )
 headers {
 contentType('application/json')
 }
 }
 }
]

Respecto a los elementos del mismo queremos destacar los siguientes detalles:

Publicación del contrato

Una vez tenemos el contrato definido, utilizaremos el plugin de spring-cloud-contract que, entre otras cosas, nos permitirá publicar contratos como un artefacto separado. La configuración inicial que utilizaremos (para maven) será la siguiente:

 org.springframework.cloud
 spring-cloud-contract-maven-plugin
 true

También hemos añadido previamente las dependencias necesarias para utilizar algunas de las funcionalidades que incluimos en el contrato:

 org.springframework.cloud
 spring-cloud-contract-dependencies
 2.0.0.BUILD-SNAPSHOT
 pom
 import

Una vez tenemos el contrato redactado, debemos hacer que esté disponible para que cualquier posible consumidor de nuestro servicio lo descargue y pueda ejecutar stubs basados en dicho contrato.

El plugin que hemos incorporado nos generará un artefacto con los contratos durante la fase de construcción de nuestra aplicación. Para lanzar dicha construcción ejecutaremos el comando: mvn clean install -DskipTests.

En el punto en el que estamos será necesario desactivar la ejecución de los tests (-DskipTests), ya que al incorporar el plugin de SCC se nos generará de forma automática, durante la fase de prueba, un test de integración asociado a la definición del contrato.

Como aún no hemos implementado el código que hace que el test funcione, no podremos publicar los contratos.

Al estar la definición del contrato en el repositorio de código del productor, es este quien debe realizar la construcción para hacer públicos los contratos. Como comentamos previamente existe otra posibilidad de crear un repositorio dedicado exclusivamente a los contratos de forma que podría ser administrado por las diversas partes.

A continuación podemos ver la parte de la ejecución correspondiente al plugin, cómo se identifica la existencia de los contratos y se crea la definición del stub:

Aquí podemos ver cómo, posteriormente, se generarán e instalarán dos artefactos correspondientes a la aplicación misma y a los contratos/stubs (customer-0.1.0-SNAPSHOT-stubs.jar):

Si inspeccionamos dicho artefacto podemos ver que, además del contrato que hemos definido, también se incluye un json con el mapeo para el stub correspondiente a cada uno de los contratos definidos (cada mapeo corresponderá a un contrato y llevará el nombre del mismo, en nuestro caso should create a customer.json’) y que luce de la siguiente manera:


{
  "id" : "6da61e7a-3ec8-4302-bbae-d7fabb8105af",
  "request" : {
    "url" : "/customer",
    "method" : "POST",
    "headers" : {
      "Content-Type" : {
        "matches" : "application/json.*"
      }
    },
    "bodyPatterns" : [ {
      "matchesJsonPath" : "$[?(@.['age'] =~ /-?(\\d*\\.\\d+|\\d+)/)]"
    }, {
      "matchesJsonPath" : "$[?(@.['email'] =~ /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,6}/)]"
    }, {
      "matchesJsonPath" : "$[?(@.['name'] =~ /^\\s*\\S[\\S\\s]*/)]"
    } ]
  },
  "response" : {
    "status" : 201,
    "body" : "{\"name\":\"{{{jsonpath this 'name'}}}\",\"email\":\"{{{jsonpath this 'email'}}}\",\"age\":\"{{{jsonpath this 'age'}}}\"}",
    "headers" : {
      "Content-Type" : "application/json"
    },
    "transformers" : [ "response-template" ]
  },
  "uuid" : "6da61e7a-3ec8-4302-bbae-d7fabb8105af"
}

Una vez se ha publicado el contrato definido de forma colaborativa entre productor y consumidor (en caso de ser equipos diferentes) con sus stubs asociados, ambas partes podrán trabajar de forma independiente por lo que los siguientes puntos se podrían producir en paralelo.

Configuración de los stubs en el consumidor

Una vez se ha generado el artefacto con los contratos y el mapeo de los stubs, ya tenemos todo lo necesario para completar nuestros tests en el lado consumidor.

Para ello primeramente añadiremos la dependencia necesaria para disponer de un ejecutor de stubs. Será necesario también añadir la dependencia previamente definida en el servidor: spring-cloud-contract-dependencies.

Aquí podemos ver la dependencia de nuestro ejecutor de stubs:

 org.springframework.cloud
 spring-cloud-starter-contract-stub-runner
 test

Con esto ya podemos volver a los tests que habíamos programado inicialmente en el consumidor. Si recordamos, nuestro test fallaba a la espera de una instancia de customer a la que invocar, ahora, gracias al contrato definido, podremos levantar un stub que reproduzca dicho comportamiento.

Para ello solo debemos añadir la siguiente anotación en nuestra clase de test:


@AutoConfigureStubRunner

Para configurar el comportamiento de nuestros stubs añadiremos también la siguiente configuración en nuestro application-test.yml


stubrunner:
  workOffline: true
  ids:
    - com.example.contract:customer:+:stubs:8080

Con esto indicamos el paquete y artefacto que deseamos descargar y como classifier indicamos que deseamos los stubs. El ‘+’ indica que descargue la última versión existente de dicho artefacto. El parámetro workOffline indica que se utilice el repositorio local para la búsqueda de dicho artefacto (más adelante explicamos la configuración necesaria para poder descargar de un repositorio remoto).

Ahora nuestro test sí pasará. Si observamos las trazas de su ejecución podremos ver algunos detalles interesantes.

En la siguiente captura se puede ver cómo resuelve, descarga y arranca el stub:

Podemos ver también cómo el stub registra el mapeo asociado a nuestro contrato:

Finalmente aquí podemos ver cómo el stub recibe la petición que indicamos en nuestro test y le asigna una respuesta en base a la definición de nuestro contrato:

Como comentamos previamente, si quisiéramos que los stubs se puedan descargar de forma remota, como la mayoría de las veces deberá ser, deberemos ajustar nuestra configuración utilizando la propiedad repositoryRoot, indicando el repositorio maven de donde queremos descargar la definición de los stubs.

Obviamente también deberemos eliminar el workOffline = true. La configuración resultante se muestra a continuación:

stubrunner:
 repositoryRoot: http://nexus.paradigmadigital.com/content/repositories/snapshots
 ids:
\- com.example.contract:customer:+:stubs:8080

En la siguiente captura podemos ver en las trazas cómo el contrato es descargado de un repositorio remoto:

Con esto el consumidor ya dispone de un contrato que le garantiza cómo será la interacción con el productor, así como de un stub que reproduzca su comportamiento, pudiendo así completar los tests de integración necesarios.

Hasta aquí hemos visto cómo sin una sola línea de código en el servidor, solo con la definición de un contrato, podemos avanzar el desarrollo de la aplicación cliente e incluso de forma automática reproducir el futuro comportamiento del servidor sin que este esté siquiera implementado. Con esto terminamos la parte que afecta al consumidor.

Validación del contrato en el productor

Ahora debemos volver a la parte productor, donde el mismo contrato que permite generar el stub en el cliente nos permitirá generar el test en el servidor para comprobar que estamos cumpliendo dicho contrato.

Para ello lo primero que debemos hacer, al igual que añadimos la dependencia del stub runner en el consumidor, es añadir la dependencia del contract verifier en el productor:

 org.springframework.cloud
 spring-cloud-starter-contract-verifier
 test

El test que se nos genere incluirá los datos y las validaciones basadas en el contrato que hemos definido, pero de todas formas sigue siendo necesario inicializar nuestra configuración de tests (perfiles spring, mockMvc, puerto de ejecución...) y esa es una labor que el plugin no puede hacer. Para ello nos permite indicar esta configuración de dos formas:

Aquí podéis consultar en detalle las configuraciones que ofrece el plugin. En nuestro caso utilizaremos la segunda opción y añadiremos la siguiente configuración al plugin:

com.example.contract.configuration.CustomerBaseIntegrationTest

Estas dos posibilidades dependen de si queremos definir una clase base específica para cada contrato o que tengamos una común para todos los contratos.

En nuestro caso centralizaremos la configuración común a todos los test de todos los contratos en la clase BaseIntegrationTest de la siguiente forma:


@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT,
 classes = CustomerApplicationTest.class)
@AutoConfigureMockMvc
@ActiveProfiles("test")
public abstract class CustomerBaseIntegrationTest {
 @Autowired
 private MockMvc mockMvc;
 @Before
 public void setup() {
 RestAssuredMockMvc.mockMvc(mockMvc);
 }
}

Una vez tenemos nuestra clase base lista ejecutaremos: mvn clean package.

Esta vez sin saltar la ejecución de los tests, para que, ante la presencia de nuestro contrato, se generen de forma automática los tests asociados al mismo. En las siguientes trazas podemos ver dónde se generan los tests y cómo se utiliza nuestra clase base:

La presencia de estos nuevos tests lógicamente provocará que fallen (ya que aún no hemos implementado ninguna funcionalidad) y que por tanto falle la construcción.

Ahora vamos a la carpeta donde se generó el código de dicho test (target/generated-test-sources/contracts/paradigma/microservices) para ver cómo luce el mismo:


import com.example.contract.configuration.CustomerBaseIntegrationTest;
import com.jayway.jsonpath.DocumentContext;
import com.jayway.jsonpath.JsonPath;
import io.restassured.module.mockmvc.specification.MockMvcRequestSpecification;
import io.restassured.response.ResponseOptions;
import org.junit.Test;
import static com.toomuchcoding.jsonassert.JsonAssertion.assertThatJson;
import static io.restassured.module.mockmvc.RestAssuredMockMvc.*;
import static org.springframework.cloud.contract.verifier.assertion.SpringCloudContractAssertions.assertThat;
public class CustomerTest extends CustomerBaseIntegrationTest {
 @Test
 public void validate_should_create_a_customer() throws Exception {
 // given:
 MockMvcRequestSpecification request = given()
 .header("Content-Type", "application/json")
 .body("{\"name\":\"Antonio\",\"email\":\"antonio@gmail.com\",\"age\":42}");
 // when:
 ResponseOptions response = given().spec(request)
 .post("/customer");
 // then:
 assertThat(response.statusCode()).isEqualTo(201);
 assertThat(response.header("Content-Type")).matches("application/json.*");
 // and:
 DocumentContext parsedJson = JsonPath.parse(response.getBody().asString());
 assertThatJson(parsedJson).field("['name']").isEqualTo("Antonio");
 assertThatJson(parsedJson).field("['age']").isEqualTo(42);
 assertThatJson(parsedJson).field("['email']").isEqualTo("antonio@gmail.com");
 }
}

Algunos puntos importantes a resaltar son los siguientes:

Si ahora ejecutamos el test, veremos que aún sigue fallando. En este caso nos indica que la petición espera como resultado un código 201 y sin embargo recibe un 404:

Pero esto entra dentro del comportamiento esperado, ya que todavía no hemos implementado el comportamiento asociado al test (recordemos que estamos siguiendo la filosofía TDD y, por tanto, el test se desarrolla primero).

En este punto, hemos generado de forma automática un test que valida la funcionalidad en base a la definición que hemos hecho en el contrato. Ahora solo faltará desarrollar la funcionalidad que pase de rojo a verde este test.

Otras consideraciones

En este caso el proceso llevado a cabo se ha caracterizado por su sencillez ya que ambos Microservicios eran gestionados por el mismo equipo. Hemos tratado de obviar algunas de las complejidades asociadas para centrarnos en el uso de Spring Cloud Contract.

Pero debemos tener en cuenta que este proceso se producirá de forma habitual siendo productor y consumidor equipos diferentes. Algunas de las consideraciones que deberemos tener en cuenta en el mundo real son las siguientes:

“Pull requests let you tell others about changes you've pushed to a repository on GitHub. Once a pull request is opened, you can discuss and review the potential changes with collaborators and add follow-up commits before the changes are merged into the repository.”

groovy

import org.springframework.cloud.contract.spec.Contract
[
 Contract.make {
 name("should create an account")
 description('''
 given:
 A new user that does not have an account
 when:
 The user requests the account creation
 then:
 The created account is returned
 ''')
 request {
 method 'POST'
 url '/account'
 body(
 name : $(c(regex(nonBlank())), p('Jesus')),
 email : $(c(regex(email())), p('jesus@gmail.com')),
 age : $(c(regex(number())), p(36)),
 username: $(c(regex(nonBlank())), p('jesus36')),
 password: $(c(regex(nonBlank())), p('password'))
 )
 headers {
 contentType(applicationJson())
 }
 }
 response {
 status 201
 body (
 name: $(p(fromRequest().body('name'))),
 email: $(p(fromRequest().body('email'))),
 age: $(p(fromRequest().body('age'))),
 username: $(p(fromRequest().body('username')))
 )
 headers {
 contentType(applicationJson())
 }
 }
 }
]

Conclusión

En un mundo en el que cada vez más la tendencia es que cada servicio que se expone se haga en forma de API y con la extensión de arquitecturas distribuidas como la de microservicios, se hace indispensable que la gestión de las APIs sea sencilla y ágil.

A su vez, con la extensión de filosofías como Continuous Delivery o Continuous Deployment, cada vez es más necesario que el proceso de pruebas sea automático y mucho más riguroso.

En este sentido, la intercomunicación de las piezas que componen nuestra aplicación suele ser un punto que muchas veces se obvia y que puede dar lugar a algunos de los problemas más graves, como es la denegación de servicio.

En este post hemos visto cómo Spring Cloud Contract nos proporciona las herramientas y la metodología para poder gestionar de forma ágil la definición de acuerdos de interfaz, sus cambios, así como que estos sean notificados a todas las partes implicadas permitiendo además que estas puedan llevar a cabo la práctica totalidad del proceso de forma independiente.

Fuentes

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

Estamos comprometidos.

Tecnología, personas e impacto positivo.