Saltar al contenido

Probar sistemas distribuidos: caos, contratos y confianza

Estrategias prácticas para arquitecturas distribuidas: contract testing entre servicios, chaos engineering, pruebas de integración y confianza sin E2E.

4 min de lectura
Pirámide de pruebas para sistemas distribuidos con las capas de pruebas unitarias, de contrato, de integración y de caos

Por qué las pruebas de extremo a extremo fallan en sistemas distribuidos

Las pruebas de extremo a extremo funcionan bien cuando controlas toda la pila tecnológica. En un sistema distribuido con decenas de servicios, bases de datos compartidas y dependencias de terceros, levantar un entorno completo para cada ejecución de pruebas resulta o bien absurdamente lento o bien absurdamente costoso. Las pruebas se vuelven inestables porque cualquier servicio puede quedar temporalmente no disponible, y los fallos te indican que algo está roto sin decirte qué es.

La solución no es sumar más pruebas de extremo a extremo, sino diseñar una estrategia de pruebas pensada para arquitecturas distribuidas.

Contract Testing entre servicios

Las pruebas de contrato verifican que dos servicios coincidan en la forma de su comunicación sin necesidad de que ambos estén ejecutándose al mismo tiempo. El Consumer define lo que espera; el Provider verifica que puede entregar esa forma.

tstypescript
// Consumer side — defines what it needs from the provider
interface UserServiceContract {
  "GET /users/:id": {
    params: { id: string };
    response: {
      id: string;
      email: string;
      name: string;
      role: "admin" | "member";
    };
  };
  "POST /users": {
    body: { email: string; name: string };
    response: { id: string; email: string; name: string };
  };
}
 
// ❌ Integration test — requires both services running
async function testUserFetch() {
  const response = await fetch("http://user-service:3000/users/123");
  expect(response.status).toBe(200);
}
 
// ✅ Contract test — verifiable independently
function generateContractTest(contract: UserServiceContract) {
  return {
    consumer: "order-service",
    provider: "user-service",
    interactions: [
      {
        description: "Get user by ID",
        request: { method: "GET", path: "/users/123" },
        response: {
          status: 200,
          body: {
            id: "123",
            email: expect.stringMatching(/.+@.+/),
            name: expect.any(String),
            role: expect.stringMatching(/^(admin|member)$/),
          },
        },
      },
    ],
  };
}
tstypescript
// Provider side — verifies it satisfies all consumer contracts
class ContractVerifier {
  constructor(
    private readonly app: Express,
    private readonly contracts: Contract[]
  ) {}
 
  async verify(): Promise<VerificationResult[]> {
    const results: VerificationResult[] = [];
 
    for (const contract of this.contracts) {
      for (const interaction of contract.interactions) {
        const response = await this.executeRequest(interaction.request);
 
        const matches = this.matchesExpectedShape(
          response,
          interaction.response
        );
 
        results.push({
          consumer: contract.consumer,
          interaction: interaction.description,
          passed: matches.success,
          failures: matches.failures,
        });
      }
    }
 
    return results;
  }
 
  private matchesExpectedShape(
    actual: any,
    expected: any
  ): { success: boolean; failures: string[] } {
    const failures: string[] = [];
 
    for (const [key, expectedValue] of Object.entries(expected.body)) {
      if (!(key in actual.body)) {
        failures.push(`Missing field: ${key}`);
      }
    }
 
    if (actual.status !== expected.status) {
      failures.push(
        `Status mismatch: expected ${expected.status}, got ${actual.status}`
      );
    }
 
    return { success: failures.length === 0, failures };
  }
 
  private async executeRequest(request: ContractRequest): Promise<any> {
    // Execute against the running provider instance
    return {} as any;
  }
}

Pruebas de integración con dependencias controladas

Cuando necesitas probar las interacciones entre servicios, usa dependencias reales en contextos controlados. Testcontainers levanta bases de datos y brokers de mensajería reales dentro de Docker, lo que te da pruebas de alta fidelidad sin depender de entornos compartidos.

tstypescript
import { GenericContainer, StartedTestContainer } from "testcontainers";
 
class TestEnvironment {
  private postgres: StartedTestContainer | null = null;
  private redis: StartedTestContainer | null = null;
 
  async setup(): Promise<{
    databaseUrl: string;
    redisUrl: string;
  }> {
    const [pg, rd] = await Promise.all([
      new GenericContainer("postgres:16")
        .withEnvironment({
          POSTGRES_DB: "test",
          POSTGRES_USER: "test",
          POSTGRES_PASSWORD: "test",
        })
        .withExposedPorts(5432)
        .start(),
      new GenericContainer("redis:7")
        .withExposedPorts(6379)
        .start(),
    ]);
 
    this.postgres = pg;
    this.redis = rd;
 
    return {
      databaseUrl: `postgresql://test:test@${pg.getHost()}:${pg.getMappedPort(5432)}/test`,
      redisUrl: `redis://${rd.getHost()}:${rd.getMappedPort(6379)}`,
    };
  }
 
  async teardown(): Promise<void> {
    await Promise.all([
      this.postgres?.stop(),
      this.redis?.stop(),
    ]);
  }
}
 
// Usage in tests
describe("OrderService integration", () => {
  let env: TestEnvironment;
  let service: OrderService;
 
  beforeAll(async () => {
    env = new TestEnvironment();
    const urls = await env.setup();
    service = new OrderService(urls.databaseUrl, urls.redisUrl);
    await service.runMigrations();
  });
 
  afterAll(() => env.teardown());
 
  it("processes order through full pipeline", async () => {
    const order = await service.create({
      userId: "user-1",
      items: [{ productId: "prod-1", quantity: 2 }],
    });
 
    expect(order.status).toBe("pending");
 
    await service.process(order.id);
    const updated = await service.findById(order.id);
 
    expect(updated.status).toBe("confirmed");
  });
});

Chaos Engineering para la resiliencia

Las pruebas de caos verifican que tu sistema se degrade con elegancia cuando fallan sus componentes. En lugar de confiar en que tu lógica de reintentos funcione, inyectas fallos y observas el comportamiento.

tstypescript
interface ChaosExperiment {
  name: string;
  hypothesis: string;
  target: string;
  faultType: "latency" | "error" | "kill" | "partition";
  duration: number;
  steadyState: SteadyStateCheck[];
  rollback: () => Promise<void>;
}
 
interface SteadyStateCheck {
  metric: string;
  condition: "above" | "below" | "equals";
  threshold: number;
}
 
const paymentLatencyExperiment: ChaosExperiment = {
  name: "Payment service high latency",
  hypothesis:
    "When payment service responds slowly, checkout degrades to " +
    "a queued state instead of timing out with an error",
  target: "payment-service",
  faultType: "latency",
  duration: 300000, // 5 minutes
  steadyState: [
    { metric: "checkout.success_rate", condition: "above", threshold: 0.95 },
    { metric: "checkout.p99_latency_ms", condition: "below", threshold: 5000 },
    { metric: "error_rate.5xx", condition: "below", threshold: 0.01 },
  ],
  rollback: async () => {
    await removeFaultInjection("payment-service");
  },
};
 
async function runExperiment(
  experiment: ChaosExperiment
): Promise<ExperimentResult> {
  // 1. Verify steady state before injection
  const baseline = await checkSteadyState(experiment.steadyState);
  if (!baseline.healthy) {
    return { status: "aborted", reason: "System not in steady state" };
  }
 
  // 2. Inject fault
  await injectFault(experiment.target, experiment.faultType);
 
  // 3. Observe during experiment
  const observations = await observe(experiment.duration, experiment.steadyState);
 
  // 4. Always rollback
  await experiment.rollback();
 
  // 5. Evaluate
  const hypothesisHeld = observations.every((o) => o.withinThreshold);
 
  return {
    status: hypothesisHeld ? "passed" : "failed",
    observations,
    recommendations: hypothesisHeld
      ? []
      : generateRecommendations(observations),
  };
}

Cómo construir el portafolio de pruebas adecuado

Cada tipo de prueba cumple un propósito distinto. El objetivo no es maximizar la cobertura en una sola capa, sino lograr una confianza equilibrada frente a todos los modos de fallo.

Tipo de pruebaQué detectaCostoVelocidad
Pruebas unitariasErrores de lógica, casos límiteBajoRápida
Pruebas de contratoDesviaciones de interfaz entre serviciosBajoRápida
Integración (testcontainers)Flujo de datos, errores de persistenciaMedioMedia
Experimentos de caosBrechas de resiliencia, fallos en cascadaAltoLenta
Monitoreo sintéticoRegresiones en producción, incumplimientos de SLAMedioContinua
tstypescript
interface TestPortfolio {
  unit: { coverage: number; runTimeSeconds: number };
  contract: { servicesCovered: number; totalServices: number };
  integration: { criticalPaths: number; coveredPaths: number };
  chaos: { experimentsRun: number; lastRunDate: string };
}
 
function assessTestConfidence(portfolio: TestPortfolio): string[] {
  const gaps: string[] = [];
 
  if (portfolio.contract.servicesCovered < portfolio.contract.totalServices) {
    const uncovered =
      portfolio.contract.totalServices - portfolio.contract.servicesCovered;
    gaps.push(`${uncovered} services lack contract tests`);
  }
 
  if (portfolio.chaos.experimentsRun === 0) {
    gaps.push("No chaos experiments — resilience is untested");
  }
 
  if (portfolio.integration.coveredPaths < portfolio.integration.criticalPaths) {
    gaps.push("Critical integration paths missing test coverage");
  }
 
  return gaps;
}

Conclusiones clave

Los sistemas distribuidos necesitan una estrategia de pruebas distinta a la de los monolitos. El Contract Testing verifica las interfaces de los servicios de forma independiente: sin entornos compartidos, sin inestabilidad causada porque un servicio ajeno esté caído. Las pruebas de integración con Testcontainers usan bases de datos y brokers reales dentro de contenedores Docker aislados, lo que aporta alta fidelidad sin compartir estado.

El Chaos Engineering no es opcional en sistemas distribuidos de producción. Inyecta latencia, errores y particiones en experimentos controlados para verificar que tu lógica de reintentos, tus circuit breakers y tus rutas de repliegue realmente funcionen. Documenta la hipótesis, observa el estado estable y ten siempre un plan de reversión.

Equilibra tu portafolio de pruebas entre todas las capas en lugar de invertir en exceso en un solo tipo. Las pruebas unitarias detectan errores de lógica a bajo costo. Las pruebas de contrato detectan desviaciones de interfaz. Las pruebas de integración detectan errores en el flujo de datos. Las pruebas de caos detectan brechas de resiliencia. Juntas, construyen la confianza que ninguna capa por sí sola puede ofrecer.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX