En arquitecturas de microservicios, donde docenas o cientos de servicios se comunican a través de límites de red, asegurar la compatibilidad entre consumidores y proveedores es crítico. El contract testing proporciona una solución al capturar las expectativas entre servicios y verificarlas independientemente, detectando cambios incompatibles antes de que lleguen a producción. Para un contexto más amplio sobre estrategias de testing de APIs, consulta Arquitectura de Testing de APIs en Microservicios. Esta guía completa explora contratos dirigidos por consumidores, el framework Pact, validación de schemas y estrategias para mantener compatibilidad hacia atrás.

Comprendiendo el Contract Testing

El Problema con el Testing Tradicional

Los enfoques tradicionales de testing de integración tienen desventajas significativas en entornos de microservicios:

Problemas del Testing End-to-End:

  • Lentos y costosos de ejecutar
  • Frágiles y propensos a fallos intermitentes
  • Requieren que todos los servicios estén ejecutándose
  • A menudo detectan problemas demasiado tarde
  • Difíciles de depurar
  • Difíciles de mantener con cobertura completa

Limitaciones del Mocking:

  • Los mocks pueden desviarse de las implementaciones reales
  • Los mocks de consumidores no verifican el comportamiento del proveedor
  • Los tests de proveedores no verifican expectativas del consumidor
  • Cambios en el proveedor pueden no romper tests mockeados

Solución del Contract Testing

El contract testing se sitúa entre tests unitarios y tests E2E, proporcionando feedback rápido mientras asegura compatibilidad real:

┌─────────────┐              ┌─────────────┐
│  Consumer   │──── uses ───▶│  Provider   │
│  Service    │              │  Service    │
└─────────────┘              └─────────────┘
      │                             │
      │ publishes                   │ verifies
      ▼                             ▼
┌──────────────────────────────────────────┐
│           Contract Broker                │
│    (Almacena contratos y resultados)     │
└──────────────────────────────────────────┘

Beneficios:

  • Ejecución rápida (no se necesitan llamadas de red)
  • Testing independiente (los servicios no necesitan ejecutarse juntos)
  • Dirigido por consumidor (captura patrones reales de uso)
  • Detección temprana de cambios incompatibles
  • Clara propiedad y documentación de APIs

Contratos Dirigidos por Consumidores

Principios de Consumer-Driven Contracts

El contract testing dirigido por consumidores (CDCT) invierte el diseño tradicional de APIs:

  1. Los consumidores definen el contrato basándose en sus necesidades reales
  2. Los proveedores deben honrar todos los contratos de consumidores a los que se han comprometido
  3. Los contratos son especificaciones ejecutables verificadas en CI/CD 4 (como se discute en Detox: Grey-Box Testing for React Native Applications). Ambos lados testean independientemente sin coordinación

El Ciclo de Vida del Contrato

1. Consumidor escribe contrato
   ↓
2. Consumidor testea contra contrato (mock provider)
   ↓
3. Consumidor publica contrato al broker
   ↓
4. Proveedor recupera contrato
   ↓
5. Proveedor verifica que puede cumplir contrato
   ↓
6. Proveedor publica resultados de verificación
   ↓
7. ¿Puede desplegar consumidor? Verificar validación del proveedor
   ↓
8. ¿Puede desplegar proveedor? Verificar todos los contratos de consumidores

Framework Pact: Guía de Implementación

Configurando Pact

Pact es el framework de contract testing dirigido por consumidores más popular, soportando múltiples lenguajes y protocolos.

Instalación (JavaScript/TypeScript):

npm install --save-dev @pact-foundation/pact

Lado del Consumidor: Escribiendo Tests Pact

Ejemplo: Consumidor de User Service

// user-service-consumer.pact.test.js
import { pact } from '@pact-foundation/pact';
import { like, eachLike } from '@pact-foundation/pact/dsl/matchers';
import { UserClient } from '../src/clients/user-client';

const provider = pact({
  consumer: 'OrderService',
  provider: 'UserService',
  port: 1234,
  log: path.resolve(process.cwd(), 'logs', 'pact.log'),
  dir: path.resolve(process.cwd(), 'pacts'),
  logLevel: 'warn'
});

describe('User Service Contract', () => {
  before(() => provider.setup());
  afterEach(() => provider.verify());
  after(() => provider.finalize());

  describe('Get user by ID', () => {
    const expectedUser = {
      id: '123',
      email: 'user@example.com',
      firstName: 'John',
      lastName: 'Doe',
      status: 'active'
    };

    before(() => {
      return provider.addInteraction({
        state: 'user 123 exists',
        uponReceiving: 'a request for user 123',
        withRequest: {
          method: 'GET',
          path: '/api/users/123',
          headers: {
            'Authorization': like('Bearer token-123')
          }
        },
        willRespondWith: {
          status: 200,
          headers: {
            'Content-Type': 'application/json'
          },
          body: like(expectedUser)
        }
      });
    });

    it('returns the user', async () => {
      const client = new UserClient('http://localhost:1234');
      const user = await client.getUser('123', 'Bearer token-123');

      expect(user).toMatchObject({
        id: '123',
        email: expect.any(String),
        firstName: expect.any(String),
        lastName: expect.any(String),
        status: expect.any(String)
      });
    });
  });
});

Lado del Proveedor: Verificando Contratos Pact

Ejemplo: Verificación del Proveedor User Service

// user-service-provider.pact.test.js
import { Verifier } from '@pact-foundation/pact';
import { startServer, stopServer } from '../src/server';

describe('Pact Verification', () => {
  let server;

  before(async () => {
    server = await startServer(3000);
  });

  after(async () => {
    await stopServer(server);
  });

  it('validates the expectations of OrderService', () => {
    const opts = {
      provider: 'UserService',
      providerBaseUrl: 'http://localhost:3000',

      // Obtener pacts del broker
      pactBrokerUrl: 'https://pact-broker.example.com',
      pactBrokerToken: process.env.PACT_BROKER_TOKEN,

      // Versión del proveedor y rama para can-i-deploy
      providerVersion: process.env.GIT_COMMIT,
      providerVersionBranch: process.env.GIT_BRANCH,

      // Publicar resultados de verificación
      publishVerificationResult: process.env.CI === 'true',

      // Manejadores de estado
      stateHandlers: {
        'user 123 exists': async () => {
          // Configurar estado de base de datos
          await db.users.create({
            id: '123',
            email: 'user@example.com',
            firstName: 'John',
            lastName: 'Doe',
            status: 'active'
          });
        },
        'user 999 does not exist': async () => {
          // Asegurar que el usuario no existe
          await db.users.deleteMany({ id: '999' });
        }
      }
    };

    return new Verifier(opts).verifyProvider();
  });
});

Estrategias de Validación de Schema

Validación con JSON Schema

JSON Schema proporciona una forma estándar de definir y validar contratos de API:

// user-schema.json
{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "required": ["id", "email", "profile"],
  "properties": {
    "id": {
      "type": "string",
      "pattern": "^[0-9]+$"
    },
    "email": {
      "type": "string",
      "format": "email"
    },
    "profile": {
      "type": "object",
      "required": ["firstName", "lastName"],
      "properties": {
        "firstName": { "type": "string", "minLength": 1 },
        "lastName": { "type": "string", "minLength": 1 }
      }
    },
    "status": {
      "type": "string",
      "enum": ["active", "inactive", "suspended"]
    }
  }
}

Validando Contra el Schema:

import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import userSchema from './schemas/user-schema.json';

const ajv = new Ajv({ allErrors: true });
addFormats(ajv);

describe('User API Schema Validation', () => {
  const validate = ajv.compile(userSchema);

  it('validates correct user object', () => {
    const user = {
      id: '123',
      email: 'user@example.com',
      profile: {
        firstName: 'John',
        lastName: 'Doe'
      },
      status: 'active'
    };

    const valid = validate(user);
    expect(valid).toBe(true);
  });

  it('rejects user with invalid email', () => {
    const user = {
      id: '123',
      email: 'invalid-email',
      profile: {
        firstName: 'John',
        lastName: 'Doe'
      },
      status: 'active'
    };

    const valid = validate(user);
    expect(valid).toBe(false);
    expect(validate.errors).toContainEqual(
      expect.objectContaining({
        instancePath: '/email',
        keyword: 'format'
      })
    );
  });
});

Patrones de Compatibilidad Hacia Atrás

Estrategias de Versionado

1. Cambios Aditivos (Seguros)

// V1: Respuesta original
{
  "id": "123",
  "name": "John Doe",
  "email": "john@example.com"
}

// V2: Agregar nuevos campos opcionales (compatible hacia atrás)
{
  "id": "123",
  "name": "John Doe",
  "email": "john@example.com",
  "phone": "+12345678901",      // Campo nuevo
  "preferences": {               // Objeto anidado nuevo
    "newsletter": true
  }
}

2. Patrón de Deprecación

// Deprecar gradualmente campos antiguos
{
  "id": "123",
  "name": "John Doe",           // Deprecado (todavía presente)
  "profile": {                   // Nueva estructura
    "firstName": "John",
    "lastName": "Doe"
  },
  "email": "john@example.com"
}

// Respuesta del proveedor incluye headers de deprecación
response.headers['X-Deprecated-Fields'] = 'name';
response.headers['X-Deprecation-Date'] = '2026-01-01';

3. Expansión y Contracción

Fase 1 (Expandir): Agregar nuevo campo junto al campo antiguo
  Proveedor devuelve ambos
  Consumidores migran gradualmente al nuevo campo

Fase 2 (Contraer): Remover campo antiguo
  Solo después que todos los consumidores se actualicen
  Se puede verificar con Pact broker

Testing de Compatibilidad Hacia Atrás

describe('Backward Compatibility Tests', () => {
  it('new provider version satisfies old consumer contracts', async () => {
    // Cargar contrato antiguo
    const oldContract = require('./pacts/v1/orderservice-userservice.json');

    // Verificar proveedor actual contra contrato antiguo
    const verifier = new Verifier({
      provider: 'UserService',
      providerBaseUrl: 'http://localhost:3000',
      pactUrls: [oldContract],
      providerVersion: '2.0.0'
    });

    await expect(verifier.verifyProvider()).resolves.not.toThrow();
  });

  it('detects breaking changes in API', async () => {
    // Validación de schema detecta campos removidos
    const oldSchema = require('./schemas/user-v1.json');
    const newResponse = {
      id: '123',
      // campo name removido (¡cambio incompatible!)
      profile: {
        firstName: 'John',
        lastName: 'Doe'
      },
      email: 'john@example.com'
    };

    const ajv = new Ajv();
    const validate = ajv.compile(oldSchema);

    expect(validate(newResponse)).toBe(false);
    expect(validate.errors).toContainEqual(
      expect.objectContaining({
        keyword: 'required',
        params: { missingProperty: 'name' }
      })
    );
  });
});

Can-I-Deploy: Despliegues Seguros

La herramienta can-i-deploy de Pact Broker previene despliegues incompatibles:

# Verificar si OrderService puede desplegarse
pact-broker can-i-deploy \
  --pacticipant OrderService \
  --version $GIT_COMMIT \
  --to-environment production

# Verificar si UserService puede desplegarse
# (verifica que todos los contratos de consumidores estén satisfechos)
pact-broker can-i-deploy \
  --pacticipant UserService \
  --version $GIT_COMMIT \
  --to-environment production

Conclusión

El contract testing es esencial para mantener arquitecturas de microservicios confiables. Al implementar contratos dirigidos por consumidores con Pact, aprovechar validación de schemas con JSON Schema y OpenAPI, y seguir patrones de compatibilidad hacia atrás, los equipos pueden desplegar con confianza mientras mantienen la estabilidad del sistema.

Conclusiones Clave:

  1. Los contratos dirigidos por consumidores aseguran que las APIs cumplan requisitos reales de uso
  2. El framework Pact proporciona herramientas completas para contract testing
  3. La validación de schema refuerza estructura y detecta cambios incompatibles temprano
  4. La compatibilidad hacia atrás requiere planificación cuidadosa y migración gradual
  5. Can-I-deploy previene despliegues incompatibles en producción

El contract testing permite a los equipos moverse rápido sin romper cosas, proporcionando la confianza necesaria para verdadero despliegue continuo en entornos de microservicios.