Reproduzierbare Testpipelines: kein Flaky, kein Zufall
Flakiness ist kein Pech, sondern fehlende Determinismus-Disziplin. Wer Zeit, Daten und Isolation kontrolliert, verwandelt die CI vom Ratespiel zurück in ein verlässliches Merge-Gate.
Es gibt einen Satz, den ich in Reviews und Beratungsterminen häufiger höre als jeden anderen: "Lass den Build nochmal laufen, der ist manchmal einfach rot." In diesem Satz steckt eine stille Kapitulation. Ein Team hat sich damit abgefunden, dass seine eigene Testsuite lügt – mal grün, mal rot, ohne dass sich am Code etwas geändert hat. Und sobald ein Team das akzeptiert, ist die teuerste Infrastruktur der Delivery entwertet: das Vertrauen in das eigene Merge-Gate.
Meine These ist unbequem, weil sie die Schuld nicht beim Zufall lässt: Flakiness ist keine Pechsträhne und kein kosmisches Rauschen. Flakiness ist ein Symptom fehlender Determinismus-Disziplin. Ein Test, der ohne Codeänderung sein Ergebnis wechselt, ist per Definition nicht-deterministisch – und Nicht-Determinismus hat immer eine physische Ursache. In fast allen Fällen lässt sich diese Ursache auf drei Achsen zurückführen: Zeit, Daten und Isolation. Wer diese drei kontrolliert, bekommt eine Suite, die ein echtes Gate ist. Wer sie schleifen lässt, bekommt ein Würfelspiel mit Continuous-Integration-Logo.
Warum das ein Business-Problem ist, kein Test-Detail
Bevor ich zur Mechanik komme, der Grund, warum ich dieses Thema in Strategiegesprächen und nicht nur in Code-Reviews platziere: Eine flaky Suite ist kein Ärgernis der Entwickler, sondern ein Bremsklotz für 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.
Kurz gesagt: Der Return liegt nicht in "schöneren Tests", sondern in verkürzten Zyklen, weniger Reibung im Team und einer höheren Auslieferungsqualität. Das ist der Grund, warum ich Determinismus als Architekturthema behandle und nicht als QA-Hygiene.
Die drei Achsen als Gate
Ich habe mir angewöhnt, die Diskussion auf ein einfaches Bild zu 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.
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.
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.
Ein Wort der Warnung, 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.
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 lehrreich: 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 treffendsten Namen: Hermetik. Google und Bazel haben den Begriff "hermeticity" geprägt – ein Test ist hermetisch, wenn gleiche Inputs garantiert gleiche Outputs liefern, weil er von Netz, geteilter Datenbank und Host-Zustand abgeschottet ist. Der Umkehrschluss ist hart: Ein nicht-hermetischer Test kann per Definition keine historisch reproduzierbaren Ergebnisse liefern.
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:
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:
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 der Elefant im Raum: 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.
Ich bin da klar in der Einordnung: 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 ist er Gift, weil er genau das Signal ausschaltet, das man braucht, um die Ursache zu finden. Fowlers Alternative ist besser und disziplinierter: Quarantäne. Ein als flaky erkannter Test wird nicht ignoriert und nicht blind wiederholt, sondern aus dem Haupt-Build herausgenommen, in einen separaten Job verschoben und mit einem Bug-Ticket versehen. Der Haupt-Build wird davon nicht rot, das Problem verschwindet aber auch nicht aus dem Blick.
# 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 weiß, dass hier eine Determinismus-Lücke ist, und ich behalte sie im Blick, bis sie gefixt ist." Nur die zweite Haltung führt langfristig zu einer vertrauenswürdigen Suite.
Fazit
Der rote Faden ist einfacher, als die Menge der Bibliotheken vermuten lässt. Flakiness ist kein Zufall, sondern ein Determinismus-Defizit, und Determinismus lässt sich auf drei kontrollierbare Achsen reduzieren: die Zeit einfrieren, die Daten festlegen, die Isolation herstellen. Eine injizierbare Uhr, geseedete oder feste Daten, hermetische Tests mit sauberem State-Reset – das ist kein Prozess-Overhead, sondern die schlanke, elegante Voraussetzung dafür, dass die Suite überhaupt etwas bedeutet.
Und der Ertrag ist eben nicht "grünere Dashboards". Der Ertrag ist ein Merge-Gate, dem das Team wieder glaubt. Erst dann trägt Continuous Integration ihren Namen zu Recht, erst dann werden die Delivery-Metriken ehrlich, und erst dann kann ein Team on-demand ausliefern, ohne bei jedem roten Build zu raten, ob es sich um einen echten Fehler oder das übliche Rauschen handelt. Wer flaky Tests als "Pech" abtut, delegiert seine wichtigste Qualitätskontrolle an den Zufall. Wer sie als fehlende Disziplin begreift, bekommt sie in den Griff – bewusst, reproduzierbar, ohne Würfel.
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
Kommentare