Zum Inhalt springen
Zurück zu Artikel

CI-Checks, die Linter nicht leisten können: API-Contract-Checks, Mobile-Pattern-Checks, Warning-Ratchets und Tests, die Stripe-Bugs festnageln

Byte Smith · · 11 Min. Lesezeit

ESLint war grün. PHPStan war grün. TypeScript kompilierte. Die Test-Suite lief durch. Und der „Online bezahlen”-Button auf jeder Rechnung, die Zenpresa mehrere Monate lang verschickte, tat beim Klicken nichts, weil die Route, auf die der Button zeigte, nie registriert worden war. Der Controller existierte. Das Frontend rief den richtigen Pfad auf. Kein Tool in der Pipeline schaute auf beide Seiten gleichzeitig.

Dieser Beitrag handelt von den Checks, die wir nach Bugs wie diesem in die Continuous Integration aufgenommen haben: Bugs, die syntaktisch perfekt, typkorrekt und falsch waren. Keiner davon ist ein Linter. Alle laufen bei jedem Pull Request im selben GitHub-Actions-Workflow wie die Linter, und jeder existiert, weil ein konkreter Bug ausgeliefert wurde.

Der API-Contract-Check

Der Rechnungszahlungs-Bug hatte eine einfache Form. Die React-App hatte eine Funktion, die eine URL für den Online-Zahlungs-Endpunkt baute. Das Laravel-Backend hatte eine Controller-Methode, die ihn bediente. Die Routendatei, die beide hätte verbinden sollen, enthielt die Zeile für einen anderen Zahlungs-Endpunkt, und die beiden waren während eines Refactorings auseinandergedriftet. Jede Schicht war in sich konsistent; die Lücke lag zwischen den Schichten.

Die Lösung war ein Skript, das auf beide Seiten schaut. Es durchläuft den API-Client des Frontends und extrahiert jeden Pfad, den es aufrufen kann. Es bittet das Backend, seine registrierten Routen aufzulisten. Dann vergleicht es beides: Jeder Pfad, den das Frontend aufruft, muss auf eine registrierte Route mit kompatibler Methode auflösen, und jede für das Frontend gedachte Route muss mindestens einen Aufrufer oder eine explizite „extern verwendet”-Annotation haben.

Das Skript hat unter zweihundert Zeilen und läuft in Sekunden. Es ist kein vollständiges Contract-Testing-Framework und versucht bewusst nicht, Request- oder Response-Strukturen zu validieren; dafür sind typisierte API-Clients und Feature-Tests da. Es beantwortet eine Frage: Zeigt jeder Button auf etwas, das existiert? In den Monaten seither hat es viermal Pull Requests scheitern lassen, jedes Mal wegen einer Route, die sonst tot ausgeliefert worden wäre.

Der Mobile-Pattern-Check

Zwei Defekte wurden auf Mobilgeräten wiederholt ausgeliefert, und beide waren gültiges JSX, gegen das kein Linter etwas einwenden konnte.

Der erste war Wrapper-Clipping. Eine Datentabelle in einem Container mit ausgeblendetem horizontalen Overflow verliert auf einem schmalen Bildschirm stillschweigend ihre rechten Spalten. Die Aktions-Buttons für Rechnungen und Termine sitzen in der äußersten rechten Spalte. Auf dem Handy existierten sie nicht. Das wurde dreimal in drei verschiedenen Tabellen ausgeliefert, weil es jedes Mal eine neue Komponente war, geschrieben von jemandem, der auf einem Laptop getestet hatte.

Der zweite war Hover-only-Controls. Eine Zeile, die ihre Bearbeiten- und Löschen-Buttons beim Hovern einblendet, ist ein normales Desktop-Muster. Touchscreens hovern nicht. Die Buttons erschienen nie, und das Feature war für jeden Nutzer am Handy unsichtbar — was für den Inhaber eines Dienstleistungsbetriebs zwischen zwei Terminen die meisten sind.

Der Check ist ein kleines Skript, das die Komponentendateien des Frontends parst und zwei Muster markiert: eine Tabelle oder ein breites Element in einem Wrapper, dessen Klassen horizontalen Overflow ausblenden, ohne inneren Scroll-Container, und ein Element, dessen einziger Sichtbarkeits-Trigger eine Hover-Variante ohne Focus- oder Immer-sichtbar-Fallback ist. Es ist eine Heuristik, hat eine Allow-List für die Handvoll legitimer Fälle und produziert gelegentlich False Positives. Es hat seit seiner Einführung außerdem beide Bugs jedes Mal vor dem Merge gefangen, und das ist die einzige Metrik, die zählt.

Der Lint-Warning-Ratchet

Der Frontend-Lint-Job scheiterte früher bei jedem Error und ignorierte Warnings. Das bedeutete, dass sich Warnings ansammelten, weil nichts sie aufhielt. Als wir die naheliegende Lösung versuchten — bei jeder Warning zu scheitern —, wurde der Job beim ersten Lauf mit einer Zahl im dreistelligen Bereich rot, niemand hatte Zeit, sie abzuarbeiten, und innerhalb einer Woche war der Job als „darf fehlschlagen” markiert und wurde komplett ignoriert. Null war das falsche Ziel.

Der Ratchet ist das richtige. Der Workflow hält die aktuelle Warning-Anzahl in einer Datei fest, zusammen mit dem Datum, an dem sie gesetzt wurde. Der Lint-Job scheitert, wenn die Anzahl steigt. Jeder kann die Zahl senken, wenn er Warnings beseitigt, und das Senken wird im Pull-Request-Template ausdrücklich ermutigt. Niemand kann sie erhöhen. Die Anzahl ist seither stetig gesunken — nicht weil jemand einen Aufräumtermin angesetzt hätte, sondern weil jeder Engineer, der eine Datei mit einer Warning anfasst, sie meist bei der Gelegenheit behebt, und der Ratchet garantiert, dass diese Arbeit nie rückgängig gemacht wird.

Derselbe Ratchet gilt für die PHPStan-Baseline im Backend und für die Anzahl der @ts-expect-error-Kommentare. Das Prinzip ist allgemein: Wenn der aktuelle Zustand weit vom Ziel entfernt ist, gaten Sie auf die Richtung der Bewegung, nicht auf das Ziel.

Stripe-Destination-Charge-Bugs mit Tests festnageln

Zenpresa nutzt Stripe Connect, damit Kunden das Unternehmen direkt bezahlen und die Plattform eine Gebühr einbehält. Drei Bugs wurden in dieser Integration ausgeliefert, jeder subtil, jeder inzwischen durch einen Test festgenagelt, der scheitert, falls er je zurückkommt.

Stripe.js auf den verbundenen Account scopen. Wenn ein Kunde eine Rechnung bezahlt, muss das Payment Element im Browser für den verbundenen Account des Unternehmens initialisiert werden, nicht für den der Plattform. Für die Plattform initialisiert, gelingt die Zahlung, und das Geld landet am falschen Ort. Der Test rendert die Zahlungsseite für einen Mandanten mit verbundenem Account und prüft, dass die clientseitige Initialisierung die Kennung des verbundenen Accounts trägt.

Webhooks ohne Account-Feld. Stripe sendet Events für verbundene Accounts mit einem Feld, das angibt, zu welchem Account sie gehören. Events für den Account der Plattform selbst tragen dieses Feld nicht. Der Webhook-Handler ging davon aus, dass das Feld immer vorhanden ist, und Events auf Plattformebene ließen den Handler abstürzen — was bedeutete, dass die Subscription-Events der Plattform selbst verloren gingen. Der Test spielt beide Arten von Events durch den Handler und prüft, dass beide verarbeitet werden.

Erstattungen im Plattform-Scope. Eine Destination Charge aus dem Kontext der Plattform statt des verbundenen Accounts zu erstatten, erzeugt einen Fehler oder, schlimmer, eine Erstattung gegen das falsche Guthaben. Der Test löst eine Erstattung für eine Charge eines verbundenen Accounts aus und prüft, dass der Request mit dem verbundenen Account im Scope abgesetzt wird.

Die größere Lektion aus diesen drei Bugs hat den Zahlungsfluss umgestaltet. Der Webhook ist die Source of Truth. Wenn Stripe meldet, dass eine Zahlung erfolgreich war, ist das das Event, das die Rechnung als bezahlt markiert. Der „Zahlung bestätigt”-Callback des Browsers, der zuerst eintrifft und sich autoritativ anfühlt, wird nur als Beschleuniger behandelt: Er stößt eine Prüfung an, die die Rechnung früher als bezahlt markieren kann, aber nichts hängt davon ab, dass er ankommt. Beide Pfade laufen in einem einzigen Compare-and-Set zusammen: nur dann als bezahlt markieren, wenn aktuell unbezahlt, in einem atomaren Statement, sodass Beschleuniger und Webhook um die Wette laufen können und die Rechnung trotzdem genau einmal als bezahlt markiert wird. Ein Test fährt beide Pfade in beiden Reihenfolgen und prüft auf genau einen Übergang.

Testen Sie den Branch, der deployt

Die früheste Version des Workflows lief bei Pull Requests und bei Pushes auf den Main-Branch. Staging deployte von einem anderen Branch, und dieser Branch stand nicht in der Trigger-Liste. Eine Zeit lang lief auf Staging Code, den CI nie gesehen hatte, weil ein Hotfix, der direkt auf den Staging-Branch gepusht wurde, jeden Check übersprang.

Das ist peinlich einfach zu beheben und peinlich verbreitet. Der Workflow triggert jetzt auf jedem Branch, von dem irgendeine Umgebung deployt, und ein Kommentar in der Trigger-Liste benennt die Umgebung, die jeder Branch versorgt. Wenn ein Branch deployt, wird er getestet. Es gibt keine Ausnahmen — auch nicht für die Person, die den Workflow geschrieben hat.

Wo jeder Check lebt

Die Pipeline besteht aus elf Jobs, und die obigen Checks fügen sich ein, statt neue Top-Level-Stufen hinzuzufügen:

  • Backend-Tests über eine Matrix von PHP-Versionen, einschließlich der Stripe-Tests und der Mandantenisolations-Tests aus dem Database-per-Tenant-Beitrag. Die Unhappy-Path-Tests des KI-Assistenten aus dem Beitrag zur Tool-Sicherheit laufen ebenfalls hier.
  • Backend-Codestil und statische Backend-Analyse, Letztere mit der geratcheten Baseline.
  • Frontend-Typprüfung, Frontend-Lint mit dem Warning-Ratchet und dem Mobile-Pattern-Check, Frontend-Tests und Frontend-Build. Der API-Contract-Check läuft im Build-Job, weil er sowohl das gebaute Frontend als auch die Routenliste des Backends braucht.
  • Einbettbare Widgets, separat typgeprüft und gebaut, weil sie auf Seiten Dritter ausgeliefert werden.
  • Docker-Build-Validierung, die das Produktions-Image genauso baut wie das Deployment und scheitert, wenn das Dockerfile von dem abgedriftet ist, was die App braucht.
  • Dependency-Audit gegen bekannte Schwachstellen in Composer- und npm-Bäumen.
  • Static Application Security Testing über das gesamte Repository.

Alles außer der Backend-Test-Matrix ist in wenigen Minuten fertig. Die gesamte Pipeline ist das Gate für Merges in jeden deployenden Branch. Die Struktur folgt dem Muster aus unserem Beitrag zu CI/CD für React-SPA-Monorepos; die Supply-Chain-Jobs folgen dem Beitrag zu Software-Supply-Chain-Sicherheit; und der Beitrag zur Absicherung von AI-Coding-Agent-Workflows erklärt, warum dieselbe Pipeline das Gate für Code ist, den Agenten schreiben — nicht nur Menschen.

Kosten vs. Abdeckung

Jeder der obigen Checks ist billig im Betrieb und war teuer im Fehlen. Contract-Check und Mobile-Pattern-Check brauchen zusammen unter einer Minute. Der Ratchet ist ein Dateivergleich. Die Stripe-Tests laufen gegen einen Fake-Anbieter und brauchen Sekunden.

Was sie kosten, ist das Schreiben: jeweils ein paar Stunden, meist am Nachmittag nach dem Fund des Bugs, solange die Gestalt des Fehlers noch frisch war. Das ist das Muster, das wir jedem Team empfehlen würden. Wenn ein Bug ausgeliefert wird, den die Pipeline hätte fangen sollen, beheben Sie nicht nur den Bug. Fragen Sie, welcher Check ihn gefangen hätte, schreiben Sie diesen Check noch am selben Tag und lassen Sie ihn auf dem Commit vor dem Fix scheitern. Eine so gebaute Pipeline ist ein Protokoll jeder Art, auf die das Produkt tatsächlich kaputtgegangen ist, und sie lässt dieselbe Sache nicht zweimal kaputtgehen.

Die Serie, die mit dem Zenpresa-Launch begann, ist die Geschichte eines kleinen Teams, das eine Plattform mit viel Oberfläche ausliefert: viele Mandanten, viele Module, ein Agent, der handeln kann, und Zahlungen, die stimmen müssen. Die Pipeline ist der Grund, warum ein kleines Team schlafen kann. Wenn Sie etwas mit ähnlicher Form bauen, ist jeder Check in diesem Beitrag einen guten Nachmittag Arbeit wert, und das Produkt, das sie schützen, steht für eine kostenlose Testphase offen, falls Sie sehen wollen, was sie zusammenhalten.

FAQ: CI-Checks jenseits von Linting

Was ist ein API-Contract-Check in CI?

Ein Skript, das jeden Endpunkt-Pfad extrahiert, den das Frontend aufrufen kann, die registrierten Routen des Backends auflistet und den Build scheitern lässt, wenn ein Frontend-Aufruf keine passende Route hat oder eine für das Frontend gedachte Route keinen Aufrufer. Es validiert keine Request- oder Response-Strukturen; es garantiert nur, dass jeder Button auf etwas zeigt, das existiert. Es existiert, weil ein Zahlungs-Button ausgeliefert wurde, der auf eine nicht registrierte Route zeigte.

Wie erzwingt man mobile-freundliche Muster in React automatisch?

Mit einem heuristischen Skript über die Komponentendateien, das zwei Muster markiert: breite Elemente in Wrappern, die horizontalen Overflow ohne inneren Scroll-Container ausblenden, und Controls, deren einziger Sichtbarkeits-Trigger eine Hover-Variante ohne Focus- oder Immer-sichtbar-Fallback ist. Es hat eine Allow-List und gelegentliche False Positives, und es hat seit seiner Einführung beide echten Bugs jedes Mal vor dem Merge gefangen.

Was ist ein Lint-Warning-Ratchet?

Eine festgehaltene Warning-Anzahl, gegen die der Lint-Job vergleicht. Der Build scheitert, wenn die Anzahl steigt; jeder darf sie senken. Er ersetzt ein „null Warnings”-Gate, das unerreichbar war und deshalb ignoriert wurde. Derselbe Ansatz gilt für Baselines der statischen Analyse und für Suppression-Kommentare. Gaten Sie auf die Richtung der Bewegung, wenn das Ziel weit entfernt ist.

Wie sollte ein Zahlungsfluss entscheiden, dass eine Rechnung bezahlt ist?

Behandeln Sie den Webhook des Zahlungsanbieters als Source of Truth und den Bestätigungs-Callback des Browsers als Beschleuniger, der die Rechnung früher als bezahlt markieren darf, von dem aber nichts abhängt. Beide Pfade laufen in einem atomaren Compare-and-Set zusammen, sodass die Rechnung genau einmal als bezahlt markiert wird, egal welcher zuerst eintrifft. Nageln Sie jeden zuvor ausgelieferten Bug mit einem Test fest, der das exakte Szenario nachspielt.

Warum sollte CI auf Staging-Branches laufen, nicht nur auf dem Main-Branch?

Weil jeder Branch, der irgendwohin deployt, für irgendjemanden Produktion ist. Wenn Staging von einem Branch deployt, auf dem CI nicht triggert, überspringen dorthin gepushte Hotfixes jeden Check. Listen Sie jeden deployenden Branch im Workflow-Trigger auf und benennen Sie die Umgebung, die er versorgt.