Consumer-driven contracts con Pact

Avanzado
Spring Boot
Spring Boot
Actualizado: 07/05/2026

Diagrama: tutorial-spring-boot-contract-testing-pact

El problema de los microservicios desacoplados

Tu plataforma tiene 14 microservicios, cada uno con su pipeline. El equipo del servicio de Pagos cambia el campo currencyCode por currency en una respuesta, despliega el viernes y se va de fin de semana. El servicio de Facturación, que consume Pagos, comienza a fallar el sábado por la mañana cuando un cliente intenta cobrar. Nadie verificó la integración porque "los tests pasaban en cada lado".

Esto pasa porque los tests del provider verifican que el endpoint funciona y los tests del consumer verifican el código del consumer (con stubs propios), pero nadie verifica que las expectativas del consumer coinciden con el comportamiento del provider. Levantar un entorno integrado en CI con todos los servicios en su versión más reciente es lento, frágil y caro.

Pact resuelve esto con consumer-driven contracts: el consumer escribe un test que define qué petición hace y qué respuesta espera; eso genera un fichero JSON (el pact) que el provider verifica contra su implementación real. Si el provider rompe el formato, su pipeline falla antes de desplegar.

Consumer-driven significa que el consumer dicta el contrato. El provider no decide qué expone; descubre qué consumers existen y qué necesita cada uno. Eso fuerza el alineamiento real entre equipos.

Cómo funciona el flujo

graph LR
    A[Consumer test] --> B[Mock server interno]
    B --> C[Pact JSON generado]
    C --> D[Pact Broker]
    D --> E[Provider verifica]
    E --> F[can-i-deploy]
    F --> G[Despliegue seguro]
  1. El consumer escribe un test contra un mock server local de Pact: "cuando llamo a GET /pagos/{id}, espero 200 con el campo id, amount y currency".
  2. Pact JUnit 6 genera el fichero JSON consumer-provider.json en target/pacts/.
  3. El consumer publica ese fichero al Pact Broker (un servidor central).
  4. El provider, en su pipeline, descarga los pacts publicados por sus consumers y los replica contra su implementación real.
  5. Si la respuesta real no coincide con el contrato, el provider build falla.
  6. Antes de desplegar, can-i-deploy --to=production verifica que la versión actual es compatible con todas las versiones desplegadas de los consumers.

Setup del consumer

<dependency>
    <groupId>au.com.dius.pact.consumer</groupId>
    <artifactId>junit5</artifactId>
    <version>4.6.14</version>
    <scope>test</scope>
</dependency>
pact:
  consumer:
    name: facturacion-service
  provider:
    name: pagos-service

Escribir un test de consumer

@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "pagos-service")
class PagoClientPactTest {

    @Pact(consumer = "facturacion-service")
    public RequestResponsePact consultaPagoPorId(PactDslWithProvider builder) {
        return builder
            .given("existe un pago con id pago_123")
            .uponReceiving("una consulta de pago por id")
                .path("/v1/payments/pago_123")
                .method("GET")
                .matchHeader("Accept", "application/json")
            .willRespondWith()
                .status(200)
                .matchHeader("Content-Type", "application/json")
                .body(new PactDslJsonBody()
                    .stringType("id", "pago_123")
                    .numberType("amount", 1500)
                    .stringMatcher("currency", "[A-Z]{3}", "EUR")
                    .stringMatcher("status", "OK|FAILED|PENDING", "OK")
                    .datetime("createdAt", "yyyy-MM-dd'T'HH:mm:ssXXX")
                )
            .toPact();
    }

    @Test
    @PactTestFor(pactMethod = "consultaPagoPorId")
    void consulta_pago_correctamente(MockServer mockServer) {
        var client = new PagoClient(RestClient.builder()
            .baseUrl(mockServer.getUrl())
            .build());

        var pago = client.consultarPorId("pago_123");

        assertThat(pago.id()).isEqualTo("pago_123");
        assertThat(pago.amount()).isEqualTo(1500);
        assertThat(pago.currency()).isEqualTo("EUR");
    }
}

Tras ejecutar el test, Pact genera target/pacts/facturacion-service-pagos-service.json:

{
  "consumer": { "name": "facturacion-service" },
  "provider": { "name": "pagos-service" },
  "interactions": [{
    "providerStates": [{ "name": "existe un pago con id pago_123" }],
    "description": "una consulta de pago por id",
    "request": {
      "method": "GET",
      "path": "/v1/payments/pago_123",
      "headers": { "Accept": "application/json" }
    },
    "response": {
      "status": 200,
      "headers": { "Content-Type": "application/json" },
      "body": {
        "id": "pago_123",
        "amount": 1500,
        "currency": "EUR",
        "status": "OK",
        "createdAt": "2026-05-07T10:00:00+00:00"
      },
      "matchingRules": { ... }
    }
  }]
}

Las matchingRules son la clave: Pact no exige que el provider devuelva exactamente EUR, sino cualquier string que matchee [A-Z]{3}. Esto evita falsos positivos cuando el provider devuelve datos reales con valores distintos a los del ejemplo.

Estados (provider states)

Cada interacción declara un estado precondicional con given(...). El provider, al verificar, deberá poder llevar su sistema a ese estado antes de replicar la petición. Es el mecanismo que permite testear casos edge como "no existe el recurso", "el usuario está bloqueado" o "la cuenta tiene saldo insuficiente".

Setup del provider

<dependency>
    <groupId>au.com.dius.pact.provider</groupId>
    <artifactId>junit5spring</artifactId>
    <version>4.6.14</version>
    <scope>test</scope>
</dependency>

Verificación en el provider

@SpringBootTest(webEnvironment = RANDOM_PORT)
@Provider("pagos-service")
@PactBroker(host = "broker.miempresa.com", scheme = "https")
@VerificationReports({ "console", "markdown" })
class PagoProviderVerificationTest {

    @LocalServerPort
    private int port;

    @Autowired
    private PagoRepository repository;

    @BeforeEach
    void setup(PactVerificationContext context) {
        context.setTarget(new HttpTestTarget("localhost", port));
    }

    @TestTemplate
    @ExtendWith(PactVerificationInvocationContextProvider.class)
    void verifyPact(PactVerificationContext context) {
        context.verifyInteraction();
    }

    @State("existe un pago con id pago_123")
    void crearPago() {
        repository.save(new Pago("pago_123", BigDecimal.valueOf(1500), "EUR", Status.OK));
    }

    @State(value = "existe un pago con id pago_123", action = StateChangeAction.TEARDOWN)
    void borrarPago() {
        repository.deleteById("pago_123");
    }
}

El provider arranca su aplicación en un puerto aleatorio, descarga todos los pacts publicados contra pagos-service desde el Pact Broker y los replica uno a uno. Cada @State prepara el sistema para una interacción específica. Si la respuesta real no cumple el contrato, el test falla con un diff detallado del campo problemático.

Pact Broker

El Pact Broker es el servidor central donde se publican y consumen los pacts. Almacena versiones, etiquetas (tags) y resultados de verificación. Se autoaloja con Docker o se contrata como PactFlow.

Publicar desde el consumer:

mvn pact:publish \
  -Dpact.broker.url=https://broker.miempresa.com \
  -Dpact.consumer.version=$GIT_SHA \
  -Dpact.consumer.tags=main

Verificar en el provider, leyendo del broker:

@PactBroker(
    host = "broker.miempresa.com",
    scheme = "https",
    consumerVersionSelectors = {
        @ConsumerVersionSelector(tag = "main", latest = true),
        @ConsumerVersionSelector(tag = "production", latest = true)
    }
)

El selector tag = "production" es esencial: garantiza que el provider verifica contra la versión que el consumer tiene desplegada hoy en producción, no solo contra la última de la rama main.

can-i-deploy: el control de despliegue

Antes de desplegar a producción, can-i-deploy consulta el broker:

"El provider pagos-service versión 1.5.0 que pretendo desplegar... ¿es compatible con todas las versiones de consumers desplegadas en producción?"

pact-broker can-i-deploy \
  --broker-base-url=https://broker.miempresa.com \
  --pacticipant=pagos-service \
  --version=$GIT_SHA \
  --to-environment=production
  • Salida 0 → safe to deploy.
  • Salida 1 → algún consumer espera comportamiento que la nueva versión rompe.

Esto se incluye en el job de CI antes del paso de deploy. Bloquea releases que romperían la integración antes de tocar producción.

Diferencias con Spring Cloud Contract

Spring Cloud Contract sigue el modelo opuesto: provider-driven. El provider escribe los contratos en Groovy/YAML y el consumer los descarga para generar stubs locales. Encaja bien cuando:

  • Provider y consumer son del mismo equipo o de equipos muy alineados.
  • La API tiene muchos consumers y el provider quiere publicar un único contrato canónico.
  • El equipo ya tiene cultura Spring y no quiere añadir un Pact Broker.

Pact, en cambio, brilla cuando:

  • Los consumers son equipos distintos, posiblemente con stacks distintos (Pact tiene clientes para JVM, Node, .NET, Go, Python, Ruby).
  • Hay riesgo real de que el provider haga cambios sin coordinar con todos los consumers.
  • Se quiere una métrica explícita de quién consume qué (Pact Broker la muestra en un grafo).
  • El control can-i-deploy se quiere automatizar antes del despliegue.

| Dimensión | Pact | Spring Cloud Contract | |-----------|------|----------------------| | Dirección del contrato | Consumer dicta | Provider dicta | | Idiomas soportados | Multi-stack | Principalmente JVM | | Servidor central | Pact Broker (obligatorio en práctica) | Repositorio Maven/Git | | Verificación gate | can-i-deploy listo de fábrica | Manual (descargar stubs y ejecutar) | | Curva de adopción | Media-alta | Baja para equipos Spring |

Ventajas y limitaciones de Pact

Ventajas:

  • Detecta breaking changes antes de producción sin levantar el entorno completo.
  • Hace explícito quién consume qué API y cómo.
  • Modelo multi-stack: el equipo de frontend (Node/TypeScript) y el de backend (Java) hablan el mismo lenguaje de contratos.
  • can-i-deploy evita despliegues que romperían a otros equipos.

Limitaciones:

  • No verifica comportamiento, solo formato. Que el provider devuelva 200 OK no significa que la lógica de negocio sea correcta.
  • No sirve para integraciones asíncronas a menos que se use Pact Message (variante para Kafka, RabbitMQ).
  • Requiere disciplina: si el equipo no escribe consumer tests, el contrato no existe.
  • El Pact Broker es un sistema más a operar (autoalojado o como SaaS).
  • Casos con respuestas con campos dinámicos o paginación compleja requieren matchers cuidadosos para evitar falsos positivos.

Buenas prácticas

  • Un pact por par consumer-provider, no un mega-contrato. Permite que cada equipo evolucione su parte sin coordinación bloqueante.
  • Estados explícitos y reproducibles: evitar given("la base de datos tiene varios pagos"). Mejor given("existe un pago con id X y amount Y").
  • Tags semánticas: main, production, staging. El provider verifica las que están vivas; las viejas se borran tras un retention period.
  • Etiquetar la versión con el SHA del commit: facilita el debugging cuando un test falla.
  • can-i-deploy antes de cada deploy a producción, no solo en main. Convertirlo en un quality gate del pipeline.
  • Nunca usar Pact como sustituto de tests E2E críticos: Pact verifica contratos, no comportamiento end-to-end. Un E2E selectivo sigue siendo necesario para flujos críticos.
  • Combinar con WireMock en la fase de desarrollo del consumer: WireMock para iterar rápido en tests del cliente, Pact para garantizar el contrato cuando el código está estable.
  • Revisar el grafo del Pact Broker periódicamente: descubre integraciones que el equipo no recordaba mantener.

Pact convierte el desacoplamiento técnico en alineamiento contractual. Los equipos siguen desplegando independientemente, pero con una red de seguridad: un pipeline que dice "tu cambio rompe a tres consumers, mira aquí". En arquitecturas con más de cinco microservicios, esa red de seguridad se paga sola la primera vez que evita un incidente de producción que habría costado horas de guardia y disculpas a un cliente.

Alan Sastre - Autor del tutorial

Alan Sastre

Ingeniero de Software y formador, CEO en CertiDevs

Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Spring Boot es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.

Más tutoriales de Spring Boot

Explora más contenido relacionado con Spring Boot y continúa aprendiendo con nuestros tutoriales gratuitos.

Aprendizajes de esta lección

Diseñar un contrato consumer-driven y entender quién dicta el formato. Escribir un test de consumer con MockServerConfig + PactDslJsonBody y generar el fichero pact JSON. Verificar el contrato en el provider con @Provider, @PactBroker y states. Subir pacts al Pact Broker desde CI y consultar can-i-deploy antes de un release. Decidir cuándo usar Pact y cuándo Spring Cloud Contract según la dirección del contrato.