Zum Inhalt springen

Verteilte Systeme testen: Chaos, Contracts und Vertrauen

Praktische Teststrategien für verteilte Architekturen: Contract Testing, Chaos Engineering, Integrationstests und Vertrauen ohne vollständige E2E.

4 Min. Lesezeit
Testpyramide für verteilte Systeme mit den Schichten Unit-Tests, Contract-Tests, Integrationstests und Chaos-Tests

Warum End-to-End-Tests in verteilten Systemen scheitern

End-to-End-Tests funktionieren gut, wenn du den gesamten Stack kontrollierst. In einem verteilten System mit Dutzenden Services, gemeinsam genutzten Datenbanken und Abhängigkeiten von Drittanbietern ist es entweder unerträglich langsam oder unbezahlbar teuer, für jeden einzelnen Testlauf eine vollständige Umgebung aufzusetzen. Die Tests werden instabil, weil jeder beliebige Service zeitweise nicht verfügbar sein kann, und ein Fehlschlag zeigt dir nur, dass etwas kaputt ist, ohne dir zu sagen, was.

Die Lösung sind nicht noch mehr End-to-End-Tests, sondern eine Teststrategie, die gezielt für verteilte Architekturen entworfen ist.

Contract Testing zwischen Services

Contract-Tests stellen sicher, dass sich zwei Services über die Form ihrer Kommunikation einig sind, ohne dass beide gleichzeitig laufen müssen. Der Consumer definiert, was er erwartet; der Provider weist nach, dass er diese Form liefern kann.

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;
  }
}

Integrationstests mit kontrollierten Abhängigkeiten

Wenn du das Zusammenspiel zwischen Services testen musst, verwende echte Abhängigkeiten in kontrollierten Kontexten. Testcontainers startet echte Datenbanken und Message-Broker in Docker und liefert dir so aussagekräftige Tests, ohne eine gemeinsam genutzte Umgebung zu benötigen.

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 für Resilienz

Chaos-Tests prüfen, ob dein System bei ausfallenden Komponenten kontrolliert und nicht abrupt degradiert. Anstatt einfach zu hoffen, dass deine Retry-Logik funktioniert, injizierst du gezielt Fehler und beobachtest das Verhalten.

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),
  };
}

Das richtige Testportfolio aufbauen

Unterschiedliche Testarten erfüllen unterschiedliche Zwecke. Das Ziel ist nicht maximale Abdeckung auf einer einzelnen Ebene, sondern ausgewogenes Vertrauen über alle Fehlermodi hinweg.

TestartWas sie aufdecktKostenGeschwindigkeit
Unit-TestsLogikfehler, GrenzfälleNiedrigSchnell
Contract-TestsSchnittstellenabweichungen zwischen ServicesNiedrigSchnell
Integration (Testcontainers)Datenfluss, PersistenzfehlerMittelMittel
Chaos-ExperimenteResilienzlücken, KaskadenausfälleHochLangsam
Synthetisches MonitoringRegressionen in Produktion, SLA-VerstößeMittelKontinuierlich
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;
}

Die wichtigsten Erkenntnisse

Verteilte Systeme brauchen eine andere Teststrategie als Monolithen. Contract Testing prüft die Schnittstellen zwischen Services unabhängig voneinander – keine gemeinsam genutzten Umgebungen, keine Instabilität, weil irgendein fremder Service gerade ausgefallen ist. Integrationstests mit Testcontainers nutzen echte Datenbanken und Message-Broker in isolierten Docker-Containern und liefern so hohe Aussagekraft, ohne sich Zustand zu teilen.

Chaos Engineering ist für verteilte Systeme in Produktion nicht optional. Injiziere Latenz, Fehler und Partitionierungen in kontrollierten Experimenten, um zu prüfen, ob deine Retry-Logik, deine Circuit Breaker und deine Fallback-Pfade wirklich funktionieren. Dokumentiere die Hypothese, beobachte den stabilen Zustand und halte immer einen Rollback-Plan bereit.

Balanciere dein Testportfolio über alle Ebenen hinweg, anstatt zu stark in einen einzigen Testtyp zu investieren. Unit-Tests finden Logikfehler günstig. Contract-Tests finden Schnittstellenabweichungen. Integrationstests finden Fehler im Datenfluss. Chaos-Tests finden Resilienzlücken. Zusammen schaffen sie das Vertrauen, das keine einzelne Ebene allein liefern kann.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX