Introducción y ejemplo de contract testing con Pact

Escrito por picodotdev el .
java planeta-codigo
Enlace permanente Comentarios

Al realizar un cambio en un API hay que ser consciente de que los cambios sean compatibles hacia atrás, de lo contrario algunos clientes de la API es probable que dejen de funcionar o tengan un comportamiento erróneo. Para asegurar que los cambios sean compatibles hacia atrás se realizan pruebas unitarias automatizadas de contrato, en Java una opción es Pact para pruebas de contrato de APIs REST.

Pact

Java

Las aplicaciones que ofrecen una API establecen un contrato con los consumidores, los consumidores al usar la API crean una dependencia. Para que un cambio API sea compatible hacia atrás no debe requerir cambios en los consumidores, si el cambio en la API requiere cambios en los consumidores estos corren el riesgo de dejar de funcionar correctamente. Los cambios no compatibles hacia atrás son un problema ya que requieren coordinar el cambio con los consumidores, los desarrolladores de la API tienen control sobre el proveedor pero en algunos casos no sobre los consumidores que deben ser adaptados por sus propietarios.

Idealmente todos los cambios deberían ser compatibles hacia atrás, sin embargo, en ocasiones no queda más alternativa que introducir un cambio no compatible. Para evitar el problema una opción es versionar la API de tal modo que los nuevos consumidores utilicen la nueva API y los consumidores de una versión anterior tengan un tiempo para adaptarse a la nueva API, durante un tiempo la API antigua y la nueva funcionan simultáneamente, pasado un tiempo y cuando los consumidores hayan pasado a usar la nueva API la versión antigua se elimina.

REST también es una forma de API en este caso ofrecida a través del protocolo HTTP y habitualmente con JSON con formato de datos, al hacer cambios en una API REST el principio de que el cambio sea compatible hacia atrás se aplica. En REST la API está formada por las direcciones de los endpoints, los parámetros de consulta, las cabeceras de la petición y de respuesta, los códigos de estado de respuesta  y los datos devueltos así como el formato de datos devueltos.

Cambios compatibles hacia atrás son añadir un nuevo campo aceptado en la petición si no es obligatorio o devuelto en la respuesta o un nuevo parámetro de consulta o un nuevo endpoint. Cambios no compatibles son por el contrario eliminar un campo en la respuesta o eliminar un endpoint. Para posibilitar cambios en una API también se suele utilizar el patrón primero expandir luego contraer o expand-contract con la cual primero se aplican cambios que añaden cosas y posteriormente cuando dejan de usarse se eliminan los que ya no se utilizan, este mismo patrón es aplicable a otras áreas como por ejemplo cambios en las bases de datos.

Para garantizar que los cambios realizados en una API no introduzcan problemas de compatibilidad hacia atrás se realizan pruebas de contrato. Son especialmente útiles cuando el equipo encargado de la parte productora es distinto del equipo de la parte consumidora ya sea en una misma empresa o de empresas diferentes.

Las pruebas de contrato

En el caso de las API con REST para garantizar que tanto el consumidor y el productor son compatibles a veces se realizan pruebas de integración o pruebas end-to-end o E2E, sin embargo, estas son costosas de realizar en tiempo y esfuerzo requerido. Para simplificar y automatizar estas pruebas de integración una opción es realizar pruebas de contrato.

Las pruebas de contrato consisten en primera instancia en que el consumidor define las interacciones que necesita, las codifica en un servidor mock que imita las respuestas del productor, realiza las pruebas unitarias y se genera un contrato con las interacciones requeridas para la parte productora.

Con el contrato generado por el consumidor las interacciones se reproducen en la parte productora, se comparan las respuestas del productor con las requeridas por el consumidor y si coinciden el productor cumple el contrato que requiere el consumidor.

Las pruebas de contrato permiten convertir las pruebas de integración en pruebas unitarias, para ello separa las pruebas del consumidor y las pruebas de productor. Una herramienta de pruebas de contrato es Pact.

La herramienta Pact

Pact es una herramienta para realizar pruebas de contrato que soporta el lenguaje Java con la librería JUnit para realizar pruebas unitarias entre otros lenguajes.

Pact en la parte consumidor también hace las funciones de servidor sin embargo adicionalmente el servidor mock de WireMock permite guardar esas interacciones y realizar las pruebas para la parte productora. Esto permite detectar problemas de que un cambio introduzca problemas de incompatibilidad y poder probar de forma desacoplada el consumidor y productor.

Pruebas de contrato con Pact

Pruebas de contrato con Pact

Ejemplo de contract testing con Pact

Este ejemplo consiste  en un endpoint REST programado usando Spring Boot que acepta un argumento opcional en la ruta y un parámetro de consulta. La respuesta consiste simplemente en un mensaje en forma de cadena que varía según la cabecera Accept-Language.

 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
package io.github.picodotdev.blogbitix.javapact;

...

@RestController
public class RestService {

    private static final Map<String, String> MESSAGES;

    static {
        MESSAGES = new HashMap<>();
        MESSAGES.put("es-ES;default", "¡Hola mundo!");
        MESSAGES.put("es-ES;hello", "¡Hola %s!");
        MESSAGES.put("en-GB;default", "Hello World!");
        MESSAGES.put("en-GB;hello", "Hello %s!");
    }

    @GetMapping(path = { "/message", "/message/{name}" })
    public String message(@RequestHeader(value = "Accept-Language", defaultValue = "en-GB") String locale, @PathVariable(required = false) String name, @RequestParam(name = "random", required = false) String random) {
        System.out.printf("Random: %s%n", random);
        String message = "";
        if (name == null || name.isBlank()) {
            String key = String.format("%s;default", locale);
            message = MESSAGES.getOrDefault(key, MESSAGES.get("en-GB;default"));
        } else {
            String key = String.format("%s;hello", locale);
            String value = MESSAGES.getOrDefault(key, MESSAGES.get("en-GB;default"));
            message = String.format(value, name);
        }
        return message;
    }
}
RestService.java

El consumidor del servicio está implementado usando la librería Retrofit para crear el cliente que abstrae de las llamadas HTTP.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package io.github.picodotdev.blogbitix.javapact;

...

public interface Service {

    @GET("/message")
    Call<String> message(@Header("Accept-Language") String acceptLanguage, @Query("random") String random);

    @GET("/message/{name}")
    Call<String> message(@Path("name") String name, @Header("Accept-Language") String acceptLanguage, @Query("random") String random);
}
Service.java
 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
package io.github.picodotdev.blogbitix.javapact;

...

public class ServiceClient implements Service {

    private Service service;

    public ServiceClient(OkHttpClient client, String baseUrl) {
        Retrofit retrofit = new Retrofit.Builder()
                .client(client)
                .addConverterFactory(ScalarsConverterFactory.create())
                .baseUrl(baseUrl).build();

        this.service = retrofit.create(Service.class);
    }

    @Override
    public Call<String> message(String acceptLanguage, String random) {
        return service.message(acceptLanguage, random);
    }

    @Override
    public Call<String> message(String name, String acceptLanguage, String random) {
        return service.message(name, acceptLanguage, random);
    }
}
ServiceClient.java

Pruebas unitarias del consumidor

En los casos de prueba se codifican las interacciones esperadas por el cliente que son proporcionadas por Pact en un servidor mock, las pruebas unitarias usan el cliente HTTP con la dirección del servidor mock de Pact que es proporcionado como un parámetro en los métodos de test.

  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
package io.github.picodotdev.blogbitix.javapact;

...

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = Main.class)
@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "serviceProvider", port = "0")
class ServiceConsumerPactTest {

    @Autowired
    private OkHttpClient okHttpClient;

    private Service service;

    @BeforeEach
    void beforeEach(MockServer mockServer) {
        service = new ServiceClient(okHttpClient, mockServer.getUrl());
    }

    @Pact(consumer="serviceConsumer")
    public RequestResponsePact defaultNameEnglishPact(PactDslWithProvider builder) {
        return builder
                .uponReceiving("get message with empty name with en-GB locale interaction")
                .method("GET")
                .headers("Accept-Language", "en-GB")
                .path("/message")
                .matchQuery("random", ".*", "16fc8a5f-b9ab-4b26-8049-81a4e7901820")
                .willRespondWith()
                .status(200)
                .body("Hello World!")
                .toPact();
    }

    @Pact(consumer="serviceConsumer")
    public RequestResponsePact customNameEnglishPact(PactDslWithProvider builder) {
        return builder
                .uponReceiving("get message with a name with en-GB locale interaction")
                .method("GET")
                .headers("Accept-Language", "en-GB")
                .path("/message/Java")
                .matchQuery("random", ".*", "16fc8a5f-b9ab-4b26-8049-81a4e7901820")
                .willRespondWith()
                .status(200)
                .body("Hello Java!")
                .toPact();
    }

    @Pact(consumer="serviceConsumer")
    public RequestResponsePact defaultNameSpanishPact(PactDslWithProvider builder) {
        return builder
                .uponReceiving("get message with empty name with es-ES locale interaction")
                .method("GET")
                .headers("Accept-Language", "es-ES")
                .path("/message")
                .matchQuery("random", ".*", "16fc8a5f-b9ab-4b26-8049-81a4e7901820")
                .willRespondWith()
                .status(200)
                .body("¡Hola mundo!")
                .toPact();
    }

    @Pact(consumer="serviceConsumer")
    public RequestResponsePact customNameSpanishPact(PactDslWithProvider builder) {
        return builder
                .uponReceiving("get message with a name with es-ES locale interaction")
                .method("GET")
                .headers("Accept-Language", "es-ES")
                .path("/message/Java")
                .matchQuery("random", ".*", "16fc8a5f-b9ab-4b26-8049-81a4e7901820")
                .willRespondWith()
                .status(200)
                .body("¡Hola Java!")
                .toPact();
    }

    @Test
    @PactTestFor(pactMethod = "defaultNameEnglishPact")
    void defaultNameEnglish(MockServer ms) throws IOException {
        String result = service.message("en-GB", UUID.randomUUID().toString()).execute().body();
        assertEquals("Hello World!", result);
    }

    @Test
    @PactTestFor(pactMethod = "customNameEnglishPact")
    void customNameEnglish() throws IOException {
        String result = service.message("Java", "en-GB", UUID.randomUUID().toString()).execute().body();
        assertEquals("Hello Java!", result);
    }

    @Test
    @PactTestFor(pactMethod = "defaultNameSpanishPact")
    void defaultNameSpanish(MockServer ms) throws IOException {
        String result = service.message("es-ES", UUID.randomUUID().toString()).execute().body();
        assertEquals("¡Hola mundo!", result);
    }

    @Test
    @PactTestFor(pactMethod = "customNameSpanishPact")
    void customNameSpanish() throws IOException {
        String result = service.message("Java", "es-ES", UUID.randomUUID().toString()).execute().body();
        assertEquals("¡Hola Java!", result);
    }
}
ServiceConsumerPactTest.java

El documento del contrato generado por el consumidor

Al finalizar las pruebas unitarias del consumidor Pact genera en el directorio build/pact un archivo con las interacciones y sus datos que ha requerido el consumidor en sus pruebas unitarias.

 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
{
    "consumer": {
      "name": "serviceConsumer"
    },
    "interactions": [
      {
        "description": "get message with a name with en-GB locale interaction",
        "request": {
          "headers": {
            "Accept-Language": "en-GB"
          },
          "matchingRules": {
            "query": {
              "random": {
                "combine": "AND",
                "matchers": [
                  {
                    "match": "regex",
                    "regex": ".*"
                  }
                ]
              }
            }
          },
          "method": "GET",
          "path": "/message/Java",
          "query": {
            "random": [
              "16fc8a5f-b9ab-4b26-8049-81a4e7901820"
            ]
          }
        },
        "response": {
          "body": "Hello Java!",
          "status": 200
        }
      },
      ...
    ],
    "metadata": {
      "pact-jvm": {
        "version": "4.2.9"
      },
      "pactSpecification": {
        "version": "3.0.0"
      }
    },
    "provider": {
      "name": "serviceProvider"
    }
  }
serviceConsumer-serviceProvider.json

Pruebas unitarias del proveedor

Este archivo es usado para realizar las pruebas unitarias de contrato de la parte proveedora, Pact lee el archivo de interacciones del consumidor y las lanza contra la parte proveedora comprobando los resultados devueltos.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package io.github.picodotdev.blogbitix.javapact;

...

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = Main.class)
@Provider("serviceProvider")
@Consumer("serviceConsumer")
@PactFolder("build/pacts")
class ServiceProviderPactTest {

    @TestTemplate
    @ExtendWith(PactVerificationSpringProvider.class)
    void pactVerificationTestTemplate(PactVerificationContext context) {
        context.verifyInteraction();
    }
}
ServiceProviderPactTest.java

Estas son las dependencias necesarias a incluir en el archivo de construcción Gradle.

 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
plugins {
    id 'application'
}

repositories {
    mavenCentral()
}

dependencies {
    implementation(platform('org.springframework.boot:spring-boot-dependencies:2.5.3'))

    implementation 'org.springframework.boot:spring-boot'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-test'

    implementation 'com.squareup.retrofit2:retrofit:2.9.0'
    implementation 'com.squareup.retrofit2:converter-scalars:2.9.0'

    testImplementation 'org.junit.jupiter:junit-jupiter:5.7.1'
    testImplementation 'au.com.dius.pact.consumer:junit5:4.2.9'
    testImplementation 'au.com.dius.pact.provider:junit5spring:4.2.9'
}

application {
    mainClass = 'io.github.picodotdev.blogbitix.javapact.Main'
}

tasks.named('test') {
    useJUnitPlatform()
}
build.gradle

Para este ejemplo por sencillez las interacciones del contrato generador por el consumidor es proporcionado a la parte proveedora a través del sistema de archivos. Pact proporciona un servidor Pact Broker donde los consumidores comparten los contratos y de donde los proveedores los obtienen para comprobarlos funcionando como un repositorio de los contratos. Se ofrece la opción de ejecutar Pact Broker mediante contenedores Docker con un archivo de Docker Compose.

Terminal

El código fuente completo del ejemplo puedes descargarlo del repositorio de ejemplos de Blog Bitix alojado en GitHub y probarlo en tu equipo ejecutando siguiente comando:
./gradlew test


Comparte el artículo: