# Reproduzierbare Testpipelines: kein Flaky, kein Zufall

URL: https://www.mikebild.dev/de/blog/reproduzierbare-testpipelines-kein-flaky/

```text
$ git rev-parse HEAD
9f2c1ab

Lauf 412   FAIL  checkout.spec.ts › Rabatt gilt bis Monatsende   (2.1s)
Lauf 413   PASS  checkout.spec.ts › Rabatt gilt bis Monatsende   (2.0s)
```

Derselbe Commit, zwei Läufe, zwei Ergebnisse. Wenn sich am Code nichts geändert hat und das Ergebnis trotzdem kippt, dann liest der Test eine weitere Eingabe, die nicht im Repository steht. „Lass den Build noch einmal laufen, der ist manchmal einfach rot“ ist deshalb keine Diagnose. Es ist das Eingeständnis, dass niemand weiß, welche Eingabe das ist.

Ein Test, der ohne Codeänderung sein Ergebnis wechselt, reagiert auf eine weitere Eingabe. Häufig steckt sie in Zeit, Daten oder fehlender Isolation. Bei Integrationstests können auch Netz, Cloud-Dienste oder andere externe Systeme real schwanken. Nicht jede Abhängigkeit lässt sich einfrieren; sichtbar machen, begrenzen und im Testtyp benennen muss ich sie trotzdem. Sonst gewöhnt sich das Team daran, dass die eigene Testsuite mal grün und mal rot meldet, und verliert das Vertrauen in sein Merge-Gate.

## Warum das ein Business-Problem ist, kein Test-Detail

Bevor es zur Mechanik geht, der Grund, warum das Thema nicht im Testverzeichnis endet: Eine flaky Suite bleibt kein Ärgernis der Entwickler. Sie bremst die gesamte Lieferfähigkeit.

Martin Fowler hat das schon 2011 auf den Punkt gebracht. Ein nicht-deterministischer Test ist nicht nur nutzlos – er ist eine „virulente Infektion“. Sobald ein einziger Test gelegentlich grundlos rot wird, gewöhnen sich alle daran, rote Builds zu ignorieren. Genau in diesem Moment schützt die Suite nichts mehr, denn ein echter Fehler sieht jetzt aus wie das übliche Rauschen. Der Schaden ist nicht der eine flaky Test, sondern der Vertrauensverlust, der sich auf die ganze Suite ausbreitet.

Das ist kein akademisches Argument. Google hat 2016 im Testing Blog Zahlen genannt: rund 1,5 Prozent aller Testläufe sind flaky. Das klingt nach wenig, bis man es hochrechnet. Flaky Tests verstopfen die Pipeline, sperren Branches, erzeugen Duplikate von Bug-Tickets und verbrennen Entwicklerzeit im großen Stil – Zeit, die in Diagnose von Fehlern fließt, die gar keine sind.

Und genau hier schließt sich der Kreis zur Delivery-Performance. Accelerate (Forsgren, Humble, Kim, 2018) verbindet die vier bekannten Metriken – Deployment Frequency, Lead Time for Changes, Change Failure Rate und Time to Restore – mit gelebter Continuous Integration und *zuverlässiger* Testautomatisierung. Wichtig ist das Wort "zuverlässig". Ein Elite-Team, das on-demand deployt, kann das nur, weil die Suite ein vertrauenswürdiges Gate ist. Eine flaky Suite ist kein Gate, sondern eine Ampel, die zufällig schaltet – und niemand deployt auf Basis einer zufälligen Ampel. Ich lese die vier Metriken übrigens bewusst immer gemeinsam: eine hohe Deploy-Frequenz bei gleichzeitig hoher Change Failure Rate ist wertlos. Determinismus in der Suite ist eine der Bedingungen, unter denen diese Metriken überhaupt ehrlich werden.

Der Gewinn liegt also nicht in "schöneren Tests". Er liegt in verkürzten Zyklen, weniger Reibung im Team und einer höheren Auslieferungsqualität. Determinismus gehört deshalb in die Architekturdiskussion und nicht auf die QA-Checkliste.

## Die drei Achsen als Gate

Die Diskussion lässt sich auf ein einfaches Bild reduzieren. Eine Pipeline läuft von links nach rechts: commit, build, test, merge. Über der Test-Stufe stehen drei Regler. Nur wenn alle drei auf "kontrolliert" stehen, wird das Gate grün und der Merge frei. Kippt einer in "unkontrolliert", entsteht flaky Rot.

```mermaid
flowchart LR
  C[commit] --> B[build] --> T[test gate] --> M[merge]
  A1[TIME<br/>frozen clock] --> T
  A2[DATA<br/>seeded fixtures] --> T
  A3[ISOLATION<br/>hermetic sandbox] --> T
  T -->|all three controlled| M
  T -->|any axis uncontrolled| F[flaky red]
```

Der Wert dieses Bildes liegt in der Diagnose. Wenn ein Build flaky ist, frage ich nicht "welcher Test spinnt", sondern "welche der drei Achsen ist unkontrolliert". Das führt fast immer schneller zur Ursache, weil es die Suche strukturiert. Gehen wir die Achsen einzeln durch.

## Achse 1: Zeit

Die häufigste versteckte Ursache. Sobald Domänencode direkt `new Date()` oder `Instant.now()` aufruft, ist er an die Wall-Clock gekoppelt – und die Wall-Clock ändert sich zwischen zwei Testläufen garantiert. Ein Test, der "der Rabatt gilt bis Monatsende" prüft, wird am 31. anders reagieren als am 1., ein Test mit `expiresAt` in genau 24 Stunden kippt an Zeitzonen- und DST-Grenzen.

Die Lösung ist keine Bibliothek, sondern eine Entwurfsentscheidung: Die Uhr wird ein injizierter Dienst. Der Domänencode fragt nie das Betriebssystem, sondern immer die Abstraktion.

```typescript
interface Clock {
  now(): Date;
}

class SystemClock implements Clock {
  now(): Date {
    return new Date();
  }
}

class FixedClock implements Clock {
  constructor(private readonly instant: Date) {}
  now(): Date {
    return this.instant;
  }
}

// domain code never calls `new Date()` directly:
function isExpired(subscription: Subscription, clock: Clock): boolean {
  return subscription.expiresAt.getTime() <= clock.now().getTime();
}
```

In Produktion läuft die `SystemClock`, im Test ein `FixedClock(new Date("2022-01-01T00:00:00Z"))`. Das ist keine exotische Idee: Java hat mit `java.time.Clock` seit Version 8 genau diese Abstraktion eingebaut, in Ruby macht Timecop das seit 2011, in JavaScript liefern Sinons Fake Timers dasselbe. Ich bevorzuge die injizierte Uhr gegenüber globalem Time-Freezing, weil sie ehrlicher ist: Sie macht die Zeitabhängigkeit im Code sichtbar, statt sie global zu überschreiben.

Damit es nicht zu einfach klingt: Zeit-Determinismus ist mehr als die Uhr einzufrieren. Zeitzonen, DST-Übergänge, Locale-abhängige Formatierung und die Sortierreihenfolge von Maps oder Sets sind eigene Nicht-Determinismus-Quellen. Die eingefrorene Uhr löst den großen Brocken, nicht alles.

## Achse 2: Daten

Die zweite Achse ist unscheinbar und richtet stillen Schaden an: Zufallsdaten. Ein `faker.name()` oder eine ungeseedete Zufallszahl macht jeden Testlauf einzigartig. Meistens geht das gut – bis der eine Datensatz auftaucht, der die Sortierung, eine Längenbegrenzung oder einen Unicode-Sonderfall kippt. Dann ist der Build rot, beim nächsten Lauf grün, und niemand kann es reproduzieren.

Die Regel ist simpel: deterministische Daten. Entweder feste Fixtures oder ein geseedeter Zufallsgenerator. Der geseedete Weg ist oft der bessere, weil er Streuung behält und trotzdem reproduzierbar bleibt.

```typescript
import seedrandom from "seedrandom";

function makeRng(seed = "1337") {
  return seedrandom(seed);
}

// same seed -> same sequence -> reproducible failures
function buildUser(rng: () => number): User {
  return {
    id: Math.floor(rng() * 1_000_000),
    tier: rng() > 0.5 ? "premium" : "free",
  };
}
```

Interessant ist, dass genau diese Regel im Contract Testing explizit festgeschrieben ist. Pact – seit 2013 im Einsatz für consumer-driven contracts – formuliert die Vorgabe "use deterministic data, avoid random data" ohne Wenn und Aber. Der Grund ist aufschlussreich: Wenn ein Kontrakt zufällige Daten enthält, wird er bei jedem Lauf als "verändert" markiert, obwohl sich fachlich nichts geändert hat. Nicht-deterministische Daten erzeugen also nicht nur flaky Tests, sondern flaky Verträge.

Contract Testing verdient hier eine kurze Würdigung, weil es die Datenachse mit der Isolationsachse verbindet. Statt dass ein Service den anderen startet, um "echt" zu testen, prüft jedes Consumer-Provider-Paar in Isolation gegen einen geteilten, deterministischen Kontrakt. Kein Netz, keine fremde Laufzeit, kein Timing – die klassischen Flaky-Quellen sind per Konstruktion ausgeschlossen.

## Achse 3: Isolation

Die dritte Achse ist die grundlegendste und trägt den treffenden Namen Hermetik. Die Bazel-Dokumentation verwendet „hermeticity“ für Builds und Tests, deren Werkzeuge und Eingaben bekannt und kontrolliert sind. Diese Abschottung von Netz, geteilter Datenbank und Host-Zustand entfernt wichtige versteckte Einflüsse; sie garantiert allein noch keinen Determinismus, wenn etwa Rennen, Zufall oder Zeit im Test unkontrolliert bleiben. Ein nicht hermetischer Test kann reproduzierbar sein, ist aber stärker von seiner Umgebung abhängig.

Die zwei praktischen Feinde der Isolation sind geteilter Zustand und Test-Reihenfolge-Abhängigkeit. Wenn Test B nur grün ist, weil Test A vorher eine Zeile in die Datenbank geschrieben hat, dann ist die Suite eine Kette, die bei jeder Umsortierung reißt. Das Gegenmittel heißt shared-nothing: Jeder Test baut seinen Zustand auf und räumt ihn wieder ab.

Für echte Abhängigkeiten wie Datenbank oder Message-Broker haben sich seit 2015 Testcontainers durchgesetzt: eine ephemere, weggeworfene Instanz pro Test statt einer geteilten, langlebigen Umgebung. Das folgende Skelett zeigt die Isolationsdisziplin kompakt – und es kombiniert bewusst alle drei Achsen:

```typescript
let db: EphemeralDatabase;
let clock: Clock;
let rng: () => number;

beforeEach(async () => {
  rng = makeRng("1337");                       // DATA: seeded
  clock = new FixedClock(FIXED_INSTANT);       // TIME: frozen
  db = await startEphemeralContainer();         // ISOLATION: hermetic
  await db.beginTransaction();
});

afterEach(async () => {
  await db.rollbackTransaction();               // state reset, shared-nothing
  await db.stop();
});
```

Der Transaktions-Rollback pro Test ist mein bevorzugter Reset-Mechanismus, wo er geht: schnell und vollständig. Wo Transaktionen nicht reichen, tut es Truncation zwischen den Tests. Entscheidend ist nur das Prinzip: Kein Test darf davon abhängen, was ein anderer hinterlassen hat.

Fowlers Ursachenliste fällt übrigens exakt auf diese drei Achsen zurück. Er nennt lack of isolation, time, asynchrony beziehungsweise remote services und resource leaks. Als Fishbone gezeichnet:

```mermaid
flowchart LR
  ISO[lack of isolation] --> S[flaky build]
  TIME[time / clock] --> S
  ASYNC[async / remote services] --> S
  LEAK[resource leaks] --> S
```

Die Asynchronität verdient einen eigenen Satz, weil sie der klassische Flaky-Generator ist. Der häufigste Fehler ist ein `sleep(500)`, in der Hoffnung, dass eine asynchrone Operation "wohl fertig" ist. Auf einem langsamen CI-Runner ist sie es manchmal nicht. Der Fix ist nie eine längere Wartezeit, sondern Polling gegen eine echte Bedingung – warten, bis der Zustand tatsächlich eingetreten ist, mit einem Timeout als Obergrenze. "Warte, bis" statt "warte, wie lange".

## Der ehrliche Umgang mit Retries

Bleibt das Thema, um das in solchen Diskussionen alle herumreden: der automatische Retry. Fast jedes CI-System bietet an, fehlgeschlagene Tests einfach nochmal laufen zu lassen, und wenn der zweite Lauf grün ist, gilt der Build als grün. Das ist verführerisch, weil es die roten Builds sofort verschwinden lässt.

Die Einordnung ist eindeutig: Retries verstecken Nicht-Determinismus, sie beseitigen ihn nicht. Als kurzfristiges Notpflaster, um ein Team nicht komplett auszubremsen, kann ein begrenzter Retry vertretbar sein. Als Dauerlösung schaltet er genau das Signal aus, das man zur Ursachenanalyse braucht. Eine zeitlich begrenzte Quarantäne kann ehrlicher sein: Der Test läuft in einem separaten Job weiter, bekommt einen benannten Verantwortlichen und eine Frist. Der Haupt-Build wird davon nicht rot; die fehlende Abdeckung im Merge-Gate bleibt bis zur Reparatur jedoch ein bewusst eingegangenes Risiko.

```yaml
# conceptual pipeline stages
stages:
  - name: test
    fail_on_red: true          # the trustworthy merge gate
  - name: contract-verify
    run: pact can-i-deploy --to prod
  - name: quarantine
    tests: tagged("flaky")
    fail_on_red: false         # does not block merge
    on_red: open_bug_ticket    # but never silently ignored
```

Der Unterschied zwischen Retry und Quarantäne ist eine Haltungsfrage. Retry sagt: „Ich hoffe, es war Zufall.“ Quarantäne sagt: „Ich kenne die Lücke, ihren Besitzer und ihre Frist.“ Ohne diese drei Angaben wird auch die Quarantäne nur zum dauerhaften Ignorieren.

## Fazit

Der rote Faden ist einfacher, als die Menge der Bibliotheken vermuten lässt. Flakiness hat eine Ursache, aber nicht jede Ursache liegt vollständig in der Testfunktion. Zeit einfrieren, Daten festlegen und Isolation herstellen räumt die häufigsten hausgemachten Quellen aus. Externe Abhängigkeiten werden simuliert, gezielt beobachtet oder in einen eigenen Integrationstest verschoben, dessen Schwankungsbreite bekannt ist.

Und der Ertrag ist eben nicht "grünere Dashboards". Der Ertrag ist ein Merge-Gate, dessen Aussage das Team einschätzen kann. Wer flaky Tests als "Pech" abtut, gibt seine Qualitätskontrolle an den Zufall ab. Wer die verborgene Eingabe sucht und verbleibende Unsicherheit offen ausweist, kann auf einen roten Build wieder sinnvoll reagieren.

## Weiterführende Quellen

- Martin Fowler: "Eradicating Non-Determinism in Tests" (2011) – https://martinfowler.com/articles/nonDeterminism.html
- Google Testing Blog: "Flaky Tests at Google and How We Mitigate Them" (2016) – https://testing.googleblog.com/2016/05/flaky-tests-at-google-and-how-we.html
- Google Testing Blog: "Where do our flaky tests come from?" (2017) – https://testing.googleblog.com/2017/04/where-do-our-flaky-tests-come-from.html
- Bazel: Hermeticity – https://bazel.build/basics/hermeticity und Test Encyclopedia – https://bazel.build/reference/test-encyclopedia
- Pact: Consumer-driven contracts, deterministic data – https://docs.pact.io/
- Forsgren, Humble, Kim: "Accelerate" (2018), DORA-Metriken – https://www.atlassian.com/devops/frameworks/dora-metrics
- Ford, Parsons, Kua: "Building Evolutionary Architectures" (1. Auflage, 2017) – https://nealford.com/books/buildingevolutionaryarchitectures.html
- Trunk-Based Development – https://trunkbaseddevelopment.com
