StackPractices
intermediate Por Mathias Paulenko

Pruebas de Contratos de API con Consumer-Driven Contracts

Cómo prevenir cambios que rompen la compatibilidad entre microservicios con contract testing consumer-driven usando Pact y validadores de OpenAPI.

Visión general

En una arquitectura de microservicios, decenas de servicios se comunican a través de APIs. Cuando un servicio cambia un campo de respuesta o elimina un código de estado, los consumidores se rompen en silencio, y el fallo suele descubrirse primero en producción. Los tests de integración capturan parte de esto, pero son lentos y necesitan todos los servicios corriendo a la vez.

El contract testing resuelve esto haciendo que cada consumidor defina sus expectativas de la API del provider (el contrato). Estos contratos se comparten, verifican independientemente, y fallan rápido cuando un provider rompe las asunciones de un consumidor. Pact es el framework más ampliamente adoptado para contract testing consumer-driven.

Cuándo usarlo

Usa esta receta cuando:

  • Gestionas 5+ microservicios con comunicación HTTP o por colas de mensajes. Consulta Integration Testing para verificar interacciones de componentes.
  • Sufres caídas en producción causadas por cambios de API en servicios upstream. Consulta Call REST API para lo que funciona con clientes API.
  • Quieres desacoplar los pipelines de despliegue para que cada servicio se despliegue de forma independiente. Consulta Microservices Patterns para guía de arquitectura distribuida.
  • Migras de monolito a microservicios y necesitas una red de seguridad en los límites de API
  • Trabajas con proveedores de API externos cuyo ciclo de publicación no controlas

Sáltalo para APIs públicas consumidas por clientes desconocidos (no puedes recoger contratos de consumidores que no conoces) y para llamadas entre módulos dentro de un monolito, donde el compilador ya protege la frontera.

Solución

Test de Consumidor (Pact JS)

const { PactV3 } = require('@pact-foundation/pact');
const { like, regex } = require('@pact-foundation/pact').MatchersV3;

const provider = new PactV3({
  consumer: 'order-service',
  provider: 'user-service',
});

describe('User Service Contract', () => {
  test('returns user by ID', async () => {
    await provider
      .given('user with id 123 exists')
      .uponReceiving('a request for user 123')
      .withRequest({
        method: 'GET',
        path: '/users/123',
      })
      .willRespondWith({
        status: 200,
        body: {
          id: like(123),
          name: like('Alice'),
          email: regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, 'alice@example.com'),
        },
      });

    await provider.executeTest(async (mockServer) => {
      const user = await fetchUser(mockServer.url, 123);
      expect(user.name).toBe('Alice');
    });
  });
});

Verificación de Provider (Pact JS)

const { Verifier } = require('@pact-foundation/pact');

describe('Pact Verification', () => {
  test('validates against consumer contracts', async () => {
    await new Verifier({
      provider: 'user-service',
      providerBaseUrl: 'http://localhost:3000',
      pactBrokerUrl: 'https://pact-broker.example.com',
      publishVerificationResult: true,
      providerBranch: process.env.GIT_BRANCH,
      stateHandlers: {
        'user with id 123 exists': async () => {
          await seedUser({ id: 123, name: 'Alice' });
        },
      },
    }).verifyProvider();
  });
});

Cada given(...) de un test de consumidor necesita su entrada correspondiente en stateHandlers del provider. Si falta una, la verificación falla con “missing provider state” aunque la API esté correcta.

Contrato generado (pact.json)

La ejecución del consumidor produce un archivo JSON por cada par consumidor-provider, que se publica en el broker:

{
  "consumer": { "name": "order-service" },
  "provider": { "name": "user-service" },
  "interactions": [
    {
      "description": "a request for user 123",
      "providerState": "user with id 123 exists",
      "request": { "method": "GET", "path": "/users/123" },
      "response": {
        "status": 200,
        "body": { "id": 123, "name": "Alice", "email": "alice@example.com" }
      }
    }
  ]
}

Gate de CI con can-i-deploy

Con los contratos en el broker, protege el pipeline del provider para que un cambio incompatible nunca llegue a producción:

# .github/workflows/provider-verify.yml
- name: Verify pacts
  run: npm run test:pact:verify

- name: Can I deploy?
  run: |
    docker run --rm pactfoundation/pact-cli:latest \
      broker can-i-deploy \
      --pacticipant user-service \
      --version ${{ github.sha }} \
      --to-environment production

can-i-deploy sale con código distinto de cero si algún contrato de consumidor está sin verificar o falla para esa versión, y el paso de despliegue no se ejecuta.

Contratos de mensajes asíncronos (Pact JS)

Pact también cubre contratos orientados a eventos: el consumidor declara la forma del mensaje que espera en lugar de una petición HTTP:

const { MessageConsumerPact } = require('@pact-foundation/pact');

const messagePact = new MessageConsumerPact({
  consumer: 'order-service',
  provider: 'inventory-events',
});

await messagePact
  .given('inventory was updated')
  .expectsToReceive('a stock.updated event')
  .withContent({ sku: like('ABC-123'), quantity: like(5) })
  .verify(async (message) => {
    expect(message.contents.sku).toBe('ABC-123');
  });

Validador OpenAPI (Python)

from openapi_spec_validator import validate_spec
import requests

spec = requests.get('https://api.example.com/openapi.json').json()
validate_spec(spec)  # valida la corrección del esquema

# Después valida las respuestas contra la spec en runtime
from openapi_core import validate_response
validate_response(spec, response)

Archivos complementarios: el repo companion de api-contract-testing tiene versiones ejecutables de los cuatro ejemplos más un workflow de GitHub Actions listo para adaptar.

Explicación

  • Consumer-driven contracts: el consumidor (cliente) escribe un test que describe exactamente lo que necesita del provider. Pact registra cada interacción y genera un archivo de contrato (JSON), uno por cada par consumidor-provider.
  • Pact Broker: el servicio que almacena los contratos publicados y mantiene una matriz de compatibilidad de qué versiones de cada servicio funcionan juntas — eso es lo que permite que cada equipo publique a su propio ritmo.
  • Provider verification: el provider reejecuta cada contrato publicado contra su API real en CI. Quita un campo o cambia un tipo y la verificación falla antes del merge.
  • Provider states: given('user with id 123 exists') es solo una etiqueta hasta que el provider la conecta — stateHandlers prepara los datos para que la petición reejecutada tenga algo real que consultar.
  • Can-I-Deploy: el comando del broker que responde “¿es seguro publicar esta versión?” contra cada contrato de consumidor que conoce.

Para la decisión arquitectónica detrás de este enfoque, consulta el patrón Contract Testing.

Variantes

HerramientaLenguajeEstilo de contratoMejor para
PactMulti (JS, JVM, Go, Python)Consumer-drivenMicroservicios internos
OpenAPI validatorsMultiProvider-drivenAPIs públicas, documentation-first
Spring Cloud ContractJVMProvider-drivenEcosistemas Spring
BigQuery data contractsSQLSchema-drivenData warehouses

Lo que funciona

  • Mantén los contratos enfocados en los campos que usas: si el consumidor solo necesita id y name, no valides el esquema de respuesta completo. Esto da al provider libertad para evolucionar los campos no usados.
  • Versiona los contratos junto al código: guarda los tests de contrato en el mismo repositorio que el servicio consumidor. CI genera y publica los contratos en cada build.
  • Usa un Pact Broker para visibilidad: sin broker, los equipos comparten archivos de contrato a mano, y eso se descontrola rápido a escala.
  • Ejecuta la verificación del provider en CI: cada pull request del provider debería verificarse contra todos los contratos de consumidores antes de integrarse.
  • No pruebes lógica de negocio en los contratos: los contratos verifican la forma de la API, no si los cálculos o las reglas de negocio son correctos.

Errores comunes

  • Contratos demasiado estrictos: verificar cada campo con valores exactos vuelve los contratos frágiles — cualquier cambio inofensivo los rompe.
  • Saltarse la verificación del provider: generar contratos sin verificarlos del lado del provider crea una falsa confianza. Los dos lados importan.
  • Guardar los contratos en unidades compartidas o por email: usa un Pact Broker, que mantiene la matriz de compatibilidad y alimenta los checks de can-i-deploy.
  • Probar a través de la UI: los tests de contrato deberían ejercitar el cliente de API directamente, no Selenium ni Playwright. Los tests de UI van en las suites E2E.

El diagrama muestra el ciclo completo: el consumidor publica un pact, el provider lo verifica en CI y el pipeline bloquea cualquier cambio que rompa el contrato.

Mermaid flowchart LR diagram

Lectura Adicional

Preguntas frecuentes

¿Puedo usar specs de OpenAPI en lugar de Pact?

Responden a preguntas distintas. Una spec de OpenAPI dice lo que el provider debería devolver; un contrato Pact dice lo que cada consumidor realmente necesita. La validación de esquema te dice que el provider se alejó de su propia spec — Pact te dice que un consumidor real se rompió. Los equipos suelen quedarse con ambos: OpenAPI para la documentación, Pact como barrera de merge.

¿El contract testing reemplaza a los tests de integración?

No — los reduce. Los contract tests verifican compatibilidad de formato de mensajes en la frontera de la API, no la lógica de negocio ni el comportamiento real de red. Mantén una capa fina de tests de integración para los flujos críticos; los contratos cubren el espacio combinatorio intermedio.

¿Cuáles son las limitaciones del contract testing?

Un contrato solo demuestra que la forma del mensaje coincide — el provider puede pasar todos los checks y seguir devolviendo un total incorrecto. Los contratos también quedan obsoletos rápido sin un broker y un gate en CI que los haga cumplir, y no dicen nada sobre auth, latencia o comportamiento ante fallos. Esa parte sigue necesitando tests de integración y E2E.

¿Cómo manejo el versionado en contract testing?

Versiona los contratos semánticamente y publícalos en un broker — Pact Broker registra qué versiones de consumidor/provider son compatibles. El check can-i-deploy bloquea los merges: responde "¿es seguro publicar esta versión?" contra cada contrato conocido. Consumidores y providers evolucionan así de forma independiente dentro de lo que el contrato permite.

¿Cuándo escribo un contract test vs un test E2E?

Contract test cuando verificas que provider y consumidor acuerdan la forma del mensaje — barato, rápido, se ejecuta sin levantar servicios reales. E2E cuando validas un recorrido de usuario sobre infraestructura real — caro y lento, reservado para los pocos flujos que tienen que funcionar de punta a punta. Si un contract test cubre el riesgo, no pagues el precio E2E por él.