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

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.
// 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)$/),
},
},
},
],
};
}// 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.
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.
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.
| Testart | Was sie aufdeckt | Kosten | Geschwindigkeit |
|---|---|---|---|
| Unit-Tests | Logikfehler, Grenzfälle | Niedrig | Schnell |
| Contract-Tests | Schnittstellenabweichungen zwischen Services | Niedrig | Schnell |
| Integration (Testcontainers) | Datenfluss, Persistenzfehler | Mittel | Mittel |
| Chaos-Experimente | Resilienzlücken, Kaskadenausfälle | Hoch | Langsam |
| Synthetisches Monitoring | Regressionen in Produktion, SLA-Verstöße | Mittel | Kontinuierlich |
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.


