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:
- Los consumidores definen el contrato basándose en sus necesidades reales
- Los proveedores deben honrar todos los contratos de consumidores a los que se han comprometido
- 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:
- Los contratos dirigidos por consumidores aseguran que las APIs cumplan requisitos reales de uso
- El framework Pact proporciona herramientas completas para contract testing
- La validación de schema refuerza estructura y detecta cambios incompatibles temprano
- La compatibilidad hacia atrás requiere planificación cuidadosa y migración gradual
- 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.