#engineering

Eine Testsuite, der man vertrauen kann: falsches Grün und falsches Rot durch Konstruktion ausgeschlossen

Für ein Backend, das Geld bewegt, haben wir Tests und Pipeline so gestaltet, dass ein Grün wirklich „bereit für das Release“ bedeutet: Die bekannten Ursachen für falsches Grün und falsches Rot sind durch die Form des Codes, durch die Typen und durch die Pipeline ausgeschlossen, statt der Disziplin des Teams überlassen.

Softwarequalität · TypeScript · Vitest · PostgreSQL · Valkey · GitHub Actions

Fast jedes Projekt hat Tests. Deutlich seltener vertraut das Team diesen Tests wirklich. Eine Suite, die gelegentlich ohne erkennbaren Grund fehlschlägt, gewöhnt alle daran, die Pipeline neu zu starten, statt den Fehler zu lesen. Eine Suite aus Mocks, die die Implementierung nachbilden, bricht bei jedem Refactoring, auch wenn sich das Verhalten nicht ändert, und bleibt grün, wenn sich ein Defekt zwischen zwei Komponenten versteckt. Eine Suite, die eine von Hand vorbereitete Umgebung braucht, läuft nicht in der Continuous Integration und am Ende überhaupt nicht mehr.

Für ein Backend, das Salden, Bestellungen und Geldbewegungen verwaltet, haben wir die Teststrategie auf einem einzigen Kriterium aufgebaut: Wenn die Tests bestehen, ist die Änderung sicher. Das ist leicht gesagt und schwer durchzuhalten, denn eine Suite kann auf zwei Arten versagen: mit falschem Rot, das lehrt, sie zu ignorieren, und mit falschem Grün, das gerade dann in Sicherheit wiegt, wenn es das nicht sollte. Eine Regel, die nur im Gedächtnis des Teams existiert, gibt bei der ersten knappen Deadline nach. Für jede bekannte Ursache haben wir deshalb nach einem Weg gesucht, sie durch Konstruktion auszuschließen, statt sie der Disziplin zu überlassen: durch die Form des Codes, durch die Typen, durch die Konfiguration oder durch eine Prüfung in der Pipeline.

Jede Entscheidung ist zusammen mit der Literatur dokumentiert, auf der sie beruht (Khorikov, Meszaros, Software Engineering at Google, Seemann, Ousterhout), und dort, wo zwischen technischen Alternativen zu wählen war, zusammen mit den Messungen, die den Ausschlag gegeben haben.

Ein Test ist etwas wert, wenn er ein Refactoring übersteht

Die verbreitetste Form des Unit-Tests isoliert jede Klasse mit Mocks von ihren Kollaborateuren. Das ergibt schnelle Suiten und hohe Abdeckung, prüft aber, wie der Code arbeitet, nicht, was er liefert: Es genügt, die Implementierung umzubauen, und Dutzende Tests werden rot, ohne dass sich das Verhalten geändert hat. Das ist das häufigste falsche Rot, und es lehrt, dass ein fehlgeschlagener Test keinen Defekt anzeigt.

Wir folgen stattdessen der „klassischen“ Schule, die Fowler in „Mocks Aren’t Stubs“ beschreibt und Khorikov weiterentwickelt hat: Ein Unit-Test prüft eine Verhaltenseinheit, schnell und isoliert von anderen Tests, nicht zwingend von allen Kollaborateuren des Codes. Jeder Test wird an Schutz vor Regressionen, Robustheit gegenüber Refactoring, schneller Rückmeldung und Wartbarkeit gemessen. Daraus folgt, dass nicht jeder Code dieselbe Art von Test verdient:

Art des Codes Typische Beispiele Strategie
Komplexe Logik, wenige Kollaborateure Gebührenberechnung, Codecs, Parser, atomare Funktionen auf den Daten erschöpfende Unit-Tests
Komplexe Logik, vermischt mit viel I/O Zulässigkeitsregeln in einem Handler, Phasen eines mehrstufigen Prozesses eine reine, tabellarisch getestete Entscheidung von einer dünnen Hülle trennen
Wenig Logik, viele Kollaborateure die meisten Handler und Tasks, sobald die Entscheidung extrahiert ist Flow-Tests: der Hauptpfad und das, was Unit-Tests nicht erreichen
Trivialer Code Registrierung der Handler, Verdrahtung keine Tests

Unit-Tests decken die Regeln erschöpfend ab; Flow-Tests decken die Verdrahtung ab, mit einem Hauptpfad pro Anwendungsfall und den verbotenen Pfaden, die von außen erreichbar sind. Die Aufteilung macht die Diagnose unmittelbar: Sind die Unit-Tests grün und schlägt ein Flow-Test fehl, liegt der Defekt in der Verdrahtung, nicht in der Regel.

Eine testbare Regel, ohne den Code in Fragmente zu zerlegen

Viele reale Handler vermischen eine Geschäftsregel mit Lese- und Schreibzugriffen auf Datenbank und Cache, sodass sich die Regel nur prüfen lässt, indem man alles startet. Der instinktive Ausweg ist, sie in viele kleine Funktionen zu zerlegen, doch das Mittel macht den Code schlechter: Der Ablauf wird unlesbar, und die Defekte landen zwischen den Funktionen, wo niemand sie von der Aufrufstelle aus sieht. Ousterhout nennt das das Problem der flachen Module; Carmack rät bei Code, der nur ein einziges Mal aufgerufen wird, ihn inline zu lassen.

Wir haben die Form übernommen, die Seemann impureim sandwich nennt (lesen, entscheiden, handeln), mit einer einzigen reinen Funktion pro Anwendungsfall, nicht einer pro Schritt. Sie darf lang und linear sein, steht in derselben Datei wie ihr Handler und wird für den Test exportiert. Sie enthält kein I/O, keine Uhr und keine Konfiguration: Der aktuelle Zeitpunkt, Zeitdauern und Grenzwerte sind Argumente. Und ohne echte Regel wird nichts extrahiert: Ein einzelner Vergleich bleibt inline. Ein eigens für diesen Artikel geschriebenes Beispiel:

type CancelDecision =
  | { action: 'cancel' }
  | { action: 'schedule'; at: Date }
  | { action: 'reject'; reason: 'already-settled' }

export const decideCancellation = (order: Order, now: Date, grace: Duration): CancelDecision => {
  if (order.status === 'SETTLED') return { action: 'reject', reason: 'already-settled' }
  const deadline = new Date(order.createdAt.getTime() + grace.milliseconds)
  return now < deadline ? { action: 'cancel' } : { action: 'schedule', at: deadline }
}

// Im Handler: lesen, entscheiden, handeln.
const decision = decideCancellation(order, new Date(), config.grace)
switch (decision.action) {
  case 'cancel':
    /* … */ break
  case 'schedule':
    /* … */ break
  case 'reject':
    /* … */ break
  default:
    decision satisfies never
}

Zwei Details machen die Form nützlich, nicht bloß ordentlich. Das erste ist der default-Zweig: Die Entscheidung gibt eine Discriminated Union zurück, und ein neues Ergebnis kompiliert erst, wenn jeder Aufrufer es behandelt. Ein Versäumnis, das ein Test womöglich nicht abdeckt, wird zum Compilerfehler. Das zweite ist der Test, eine test.each-Tabelle, in der jede Zeile einen Namen in natürlicher Sprache trägt, etwa „eine bereits abgewickelte Bestellung kann nicht storniert werden“. Von oben nach unten gelesen, ist die Tabelle die Spezifikation der Regel; auch noch offene Produktfragen kommen hinein, als test.todo mit der ausformulierten Frage.

Die Form trägt auch in weniger linearen Fällen: Braucht die Entscheidung Zwischenlesezugriffe, nutzen wir Khorikovs Trilemma (von Vollständigkeit der Logik, ihrer Reinheit und Performance kann man nur zwei haben) und entscheiden ausdrücklich, Fall für Fall, worauf wir verzichten. Und wenn sichergestellt werden muss, dass „C nie zu B führt“, bevorzugen wir in dieser Reihenfolge: diese Garantie zu einer Zeile der Tabelle machen, dann den Pfad nicht darstellbar machen (Schritt B akzeptiert nur den Typ, den A erzeugt, und ein Aufruf mit dem Ergebnis von C kompiliert nicht). Erst wenn sich die Koordination über die Zeit oder über mehrere Prozesse erstreckt, wird die Bedingung Teil des Schreibvorgangs, mit einem bedingten Update, bei dem die Anzahl der betroffenen Zeilen geprüft wird. Zuerst beseitigt man das Problem, dann macht man es unmöglich, es überhaupt hinzuschreiben, und erst zuletzt überwacht man es.

Keine Mocks für das, was wir kontrollieren: Genau dort entsteht falsches Grün

Ein Mock der eigenen Datenbank antwortet das, was sein Autor von der Datenbank erwartet. Ist die Erwartung falsch, irren Code und Test gemeinsam, und der Test bleibt grün. Wiederum mit Khorikov unterscheiden wir deshalb verwaltete Abhängigkeiten (unsere Datenbank, unser Cache, die Funktionen, die im Datastore laufen) von nicht verwalteten (Zahlungsanbieter, E-Mail-Dienste, Speicher von Drittanbietern).

Verwaltete Abhängigkeiten sind immer echt, nie simuliert: Sie sind Implementierungsdetails, und sie zu simulieren heißt, Tests zu schreiben, die die Implementierung kennen. Nicht verwaltete Abhängigkeiten sind der einzige Ort, an dem ein Ersatz zulässig ist, sofern man die Grenze besitzt, die ersetzt wird („Only mock types you own“, Freeman und Pryce), und in der Präferenzreihenfolge von Google: echt, Fake, Stub und erst ganz zuletzt Mock. Der E-Mail-Anbieter wird zum Beispiel allein per Konfiguration durch einen lokalen Dienst ersetzt, der die gesendeten Nachrichten aufbewahrt.

Daraus folgt eine weniger offensichtliche Regel: niemals Spies auf die Interna eines SDK setzen. Das ist eine klassische Falle: Ein Spy auf einer ORM-Methode kann das Verhalten der nachfolgenden Aufrufe verändern, sodass der Task „gelingt“, ohne etwas geschrieben zu haben, und der Test grün bleibt. Das ist das gefährlichste falsche Grün, weil es in Sicherheit wiegt.

Die Kehrseite der Medaille ist, dass Tests nicht in den Produktionscode gelangen (Meszaros, Test Logic in Production): kein if (test), keine zur Laufzeit ausgewählten Fake-Implementierungen. Die Tests steuern die Anwendung nur über die Konfiguration, die sie auf die Testinfrastruktur zeigen lässt, und der in der Pipeline geprüfte Code ist derselbe, der in Produktion läuft, keine Variante davon.

Eine Architekturvorgabe wird zum Test

All das funktioniert nur, wenn sich der Code von den Tests starten lässt. Eine häufige Hürde in Node-Backends sind Singletons, die beim Import erzeugt werden: Module, die gleich beim Import Umgebungsvariablen lesen, einen Connection-Pool öffnen oder einen Server erzeugen. In solchem Code braucht selbst die einfachste reine Funktion eine vollständige Umgebung, um getestet zu werden.

Die Lehrbuchantwort ist eine vollständige Dependency Injection durch jeden Handler und jeden Task. Wir haben sie bewusst verworfen: Sie hätte die gesamte Codebasis berührt, für einen Nutzen, den eine explizite Composition Root im Sinne von Seemann und van Deursen bereits liefert. loadConfig(env) validiert die Konfiguration, start(config) erzeugt Datenbank, Cache-Pool, WebSocket-Server und externe Clients, stop() schließt sie. Entrypoints und Suite rufen dasselbe start() auf, die Suite mit einer Testkonfiguration, die gegen das Schema der Produktion typisiert ist. Die Module stellen ihre Ressourcen als ESM-Bindings bereit, die von start() zugewiesen werden, sodass sich für den importierenden Code nichts ändert.

Als bloße Konvention würde diese Eigenschaft bei der ersten unachtsamen Änderung verloren gehen. Deshalb importiert ein Unit-Test jedes Modul des Backends, ausgenommen die Entrypoints, ohne etwas zu starten, und schlägt fehl, wenn ein Modul beim Import die Konfiguration liest oder einen Client erzeugt. Eine Vorgabe, die sonst nur im Gedächtnis des Teams existieren würde, wird zu einem Test, den keine künftige Änderung stillschweigend umgehen kann. Nach derselben Logik verweigert vor jeder destruktiven Operation ein Guard den Start, wenn Datenbank oder Cache nicht exakt die Dienste der Testinfrastruktur sind.

Dieselbe Engine wie in der Cloud, kein bequemer Ersatz

Steht fest, dass Datenbank und Cache echt sind, bleibt zu entscheiden, wie echt. Die bekannten Abkürzungen, etwa SQLite statt PostgreSQL, ein simulierter In-Memory-Cache oder eine Standalone-Instanz statt des Clusters, machen die Suite leichter startbar, aber jede von ihnen verschiebt einen Teil des Verhaltens aus dem heraus, was der Test prüft.

Der Fall des Clusters ist der lehrreichste. In einem Valkey-Cluster sind Befehle auf mehreren Schlüsseln, Transaktionen und Skripte nur zulässig, wenn alle beteiligten Schlüssel zum selben Hash-Slot gehören; eine Standalone-Instanz kennt diese Einschränkung nicht, und ein Test kann mit einer Operation bestehen, die ein Cluster ablehnen würde. In den Cloud-Umgebungen läuft der Cache immer als Cluster, deshalb läuft der Cache in den Tests immer im Cluster-Modus, mit mehreren Mastern: Ein Sharding-Fehler muss in den Tests auffallen, nicht erst nach dem Release. Nach demselben Kriterium läuft PostgreSQL in derselben Version wie die verwaltete Datenbank und Valkey in dem Open-Source-Release, das dem des verwalteten Dienstes am nächsten kommt. Die übrigen Alternativen wurden aus ebenso konkreten Gründen verworfen, die dokumentiert sind, weil die Frage wiederkommen wird:

Alternative Warum wir sie verworfen haben
SQLite statt PostgreSQL die Queries nutzen die Volltextsuche und die jsonb-Operatoren von PostgreSQL; kein SELECT … FOR UPDATE, ausgerechnet dort, wo ein System, das Geld bewegt, heikel ist
Schlüsselpräfixe zur Isolation der Tests an der Client-Bibliothek überprüft: einige Stream-Befehle erhalten kein Präfix, und die vom Server zurückgegebenen Schlüssel kommen bereits mit Präfix zurück, sodass sie bei der Wiederverwendung das Präfix doppelt erhalten
Ein simulierter In-Memory-Cache unterstützt weder die Consumer Groups der Streams noch die Bibliotheken, die die serverseitigen Funktionen nutzen

Realitätstreue bedeutet weder Langsamkeit noch Fragilität. PostgreSQL läuft auf tmpfs mit den vom Projekt dokumentierten „nicht dauerhaften“ Einstellungen (fsync, synchronous_commit und full_page_writes deaktiviert), denn eine Testdatenbank braucht keine Absturzsicherheit. Die Knoten des Clusters laufen in einem einzigen Container: Die Clients verbinden sich mit der Adresse, die jeder Knoten ankündigt, und die Knoten nutzen dieselbe Adresse, um miteinander zu kommunizieren; mit einem Container pro Knoten ist die angekündigte Adresse deshalb unter macOS vom Host aus nicht erreichbar, oder sie bricht die interne Kommunikation. In einem einzigen Netzwerk-Namespace funktioniert 127.0.0.1 für beides, und dieselbe Compose-Datei läuft identisch unter macOS, Linux und in der CI.

Auch die Isolation zwischen den Tests wurde anhand von Zahlen entschieden. Eine „Template“-Datenbank zu migrieren, dauert etwa 0,8 Sekunden und geschieht einmal pro Durchlauf; sie für jeden Test-Worker zu klonen, kostet etwa 11 Millisekunden. Eine Datenbank für jeden einzelnen Test zu klonen, hätte etwa 27 Millisekunden pro Fall gekostet und jedes Mal einen neuen ORM-Client erfordert: verworfen. Tabellen und Cluster vor jedem Test zu leeren, kostet dagegen etwa 11 Millisekunden, die Prüfung am Testende, dass keine Arbeit in der Queue zurückgeblieben ist, etwa 7. Ein vollständiger Flow-Test, vom HTTP-Request bis zur Ausführung aller Tasks, liegt bei rund 50 Millisekunden. Bei diesen Kosten ist echte Infrastruktur kein Luxus, den man sich für wenige Tests aufspart: Sie ist der normale Weg, einen Flow-Test zu schreiben.

Ein instabiler Test ist ein Bug, kein Ärgernis

Ein Test, der gelegentlich fehlschlägt, ist das falsche Rot schlechthin: Nach ein paar Neustarts liest niemand mehr den Fehler, und an dem Tag, an dem der Fehlschlag echt ist, bleibt er unbemerkt. Fehlgeschlagene Tests automatisch zu wiederholen, verdeckt das Symptom und lässt die Ursache bestehen. Die Ursachen der Instabilität sind jedoch bekannt, und für jede haben wir eine präzise Regel, die sie an der Wurzel packt.

  • Zustand: Jeder Test beginnt mit einer leeren Datenbank und einem leeren Cluster; die Daten werden eindeutig generiert, und die Assertions betreffen nur die Entitäten des jeweiligen Tests, nie globale Zählungen.
  • Asynchronität: kein sleep, keine Timeouts, kein Polling. Asynchrone Arbeit wird explizit ausgeführt, bis die Queue leer ist, mit derselben Pipeline wie beim Produktions-Worker. Wenn ein Test warten muss, ist es das Design, das sich ändern muss.
  • Zeit: Reine Entscheidungen erhalten den aktuellen Zeitpunkt als Argument, und geplante Jobs stellen ein run({ now }) ohne Scheduling und ohne Leader Election bereit (das Humble Object von Meszaros).
  • Entfernte Dienste: Kein Test hängt vom Netzwerk oder von der Verfügbarkeit Dritter ab.
  • Verbleibende Instabilität: Ein instabiler Test kommt, falls einer auftaucht, noch am selben Tag mit einem Ticket in Quarantäne.

Die Prüfung der Queue am Testende schließt auch ein typisches falsches Grün asynchroner Systeme aus: Unvollständig gebliebene Arbeit lässt den Test fehlschlagen, statt unbemerkt zu bleiben. Und bevor der Ansatz übernommen wurde, wurde die Stabilität überprüft, nicht angenommen: wiederholte Durchläufe der Integrationssuite und der gesamten Suite, mit zufällig gemischter Testreihenfolge, alle grün.

Eine Pipeline, die früh fehlschlägt und die Regeln verbindlich macht

Die Pull-Request-Pipeline soll schnell Rückmeldung geben und die Regeln in Prüfungen verwandeln. Heikel ist bei der Geschwindigkeit, nur auszuführen, was die Änderung berührt: Ein Filter allein auf die Pfade der jeweiligen Anwendung des Monorepos würde die Tests ausgerechnet dann überspringen, wenn sich das ändert, wovon alle abhängen. Deshalb werden die gemeinsamen Abhängigkeiten (gemeinsame Bibliotheken, Lockfile, Workspace-Konfiguration, Patches von Abhängigkeiten, die Pipeline selbst) einmal deklariert und in den Filter jeder Anwendung aufgenommen. Eine Änderung an der Administrationsoberfläche startet die Integrationstests des Backends nicht; eine Änderung am Lockfile startet sie alle.

Die Prüfungen laufen von der günstigsten zur teuersten, sodass ein Typfehler den Job abbricht, ohne auf Container und Datenbanken zu warten. Streng nacheinander würde man die Wartezeit auf die Infrastruktur allerdings am Ende vollständig bezahlen. Deshalb startet die Testinfrastruktur direkt nach dem Checkout im Hintergrund, mit den nativen Background-Steps von GitHub Actions, und der Warte-Step führt erst vor den Integrationstests wieder mit ihr zusammen und übernimmt ihr Ergebnis.

Ja Nein Pull Request Ermittlung der betroffenen Anwendungen Policy für personenbezogene Daten: immer Frontend: Typecheck, Lint, Build Backend Start der Testinfrastruktur im Hintergrund Typecheck von Code und Tests Lint Build Unit-Tests Warten, bis die Infrastruktur bereit ist Integrationstests Job fehlgeschlagen? Logs der Testdienste Prüfungen bestanden

Die zweite Funktion der Pipeline trägt die gesamte Strategie: verbindlich zu machen, was anderswo eine gute Gewohnheit wäre. Ein einziger Typecheck deckt Produktions- und Testcode ab, und die Testkonfiguration wird mit satisfies gegen das Schema der Produktionskonfiguration geprüft. Der Lint kodiert projektspezifische Vorgaben: Der generierte Client des ORM hat mehrere Einstiegsdateien, und zwei davon zu importieren bedeutet, in den Tests die .env-Datei der Entwicklung erneut zu laden; deshalb verbietet eine no-restricted-imports-Regel den zweiten Einstiegspunkt und verweist auf den richtigen. Ein subtiler Defekt lässt sich so gar nicht erst einschleppen.

Dasselbe gilt für personenbezogene Daten. Bei jedem Pull Request prüft ein Job die Policy, die personenbezogene Daten und Zugangsdaten aus den an das Error-Tracking-System gesendeten Events und aus den Logs entfernt. Für die auditierten DTOs gibt es eine auf Typebene erschöpfende Klassifizierung, Feld für Feld, redact oder retain: Kommt in einem dieser DTOs ein Feld hinzu, schlägt der Typecheck fehl, bis jemand ausdrücklich entscheidet, ob dieses Feld die Anwendung verlassen darf. Eine Datenschutzentscheidung, die sonst gern vergessen wird, wird zum Kompilierfehler.

Die Zeit bis zur Rückmeldung wird gemessen und an der Ursache korrigiert

Eine langsame Suite wird seltener ausgeführt, und eine seltener ausgeführte Suite schützt weniger. Deshalb behandeln wir Zeit als gemessene Anforderung.

Der lehrreichste Fall betrifft das Plugin, das TypeScript-Typen zur Build-Zeit in Laufzeit-Validatoren umwandelt. Ein erster Kaltstart der Unit-Tests dauerte 67 Sekunden. Die schnellsten Antworten wären gewesen, die Timeouts zu erhöhen oder auf das Plugin zu verzichten. Bei der Analyse haben wir stattdessen die Ursache gefunden: Es baute für jede Datei ein eigenes TypeScript-Programm und löste Module wegen der von der Build-Umgebung übergebenen Aliase über einen Resolver ohne Cache auf, wobei es für jede Datei den gesamten Importgraphen erneut auf der Festplatte durchlief. Ein Patch von wenigen Zeilen, über den Patch-Mechanismus des Paketmanagers angewendet und damit versioniert und reproduzierbar, sorgt dafür, dass sich alle Dateien einen Auflösungs-Cache teilen: von 67 auf 19 Sekunden.

Was ein Grün bedeutet

Das Ergebnis ist, dass das Grün der Pipeline eine präzise Bedeutung hat. Jede Geschäftsregel, die in eine reine Entscheidung extrahiert wurde, ist Fall für Fall durch eine Tabelle abgedeckt, die sich wie eine Spezifikation liest. Die Verdrahtung zwischen API, Queues, Datastore und Datenbank wird auf derselben Engine und im selben Cluster-Modus geprüft, die auch in den Cloud-Umgebungen laufen. Unvollständig gebliebene asynchrone Arbeit lässt den Test fehlschlagen. Und die Vorgaben, die all das zusammenhalten, werden vom Compiler, vom Lint oder von einem Test geprüft, statt im Gedächtnis behalten zu werden.

Für das Team bedeutet das, den Code mit Zuversicht zu ändern, mit Tests, die Refactorings überstehen, und einem Leitfaden, der für jede Situation einen Referenztest zum Kopieren nennt. Für den Kunden bedeutet es, häufiger und mit weniger Unsicherheit zu releasen, mit einem Schutz personenbezogener Daten, den die Pipeline bei jeder Änderung überprüft.

Die Arbeit zeigt, wie wir Qualität generell angehen: Eine Regel, auf die es ankommt, bleibt keine Empfehlung, sondern wird zu etwas, das der Code, die Typen oder die Pipeline durchsetzen. So hört eine Suite auf, ein Ritual zu sein, und wird zum Werkzeug, mit dem über ein Release entschieden wird.

Der Ansatz ist auf Wachstum ausgelegt. Für die Abläufe, die Geld bewegen, ist der nächste Schritt zustandsbehaftetes Property-based Testing: zufällige Folgen realer Operationen, die gegen das System ausgeführt werden, mit Invarianten, die nach jedem Schritt geprüft werden (ein Saldo ist nie negativ, Geld bleibt erhalten, ein Endzustand geht immer aus seinem Vorgängerzustand hervor).