Automatisierte Checks, die Ihren Code-Review-Aufwand reduzieren
Automatisieren Sie Code-Reviews mit OpenAPI, dependency-cruiser und Stryker-Mutationstests. Richten Sie CI-Prüfungen ein, die Fehler erkennen und Reviews entlasten.
Automatisiertes Code-Review in seiner deterministischen Form ist eine Reihe von CI-Checks, die einen Pull Request fehlschlagen lassen, sobald der Code gegen eine Regel verstößt. Reviewer können sich dann auf Design, Absicht und Produkt-Fit konzentrieren, statt sich mit mechanischen Fehlern aufzuhalten.
Coding-Agents eröffnen Pull Requests inzwischen schneller, als jemand sie Zeile für Zeile lesen kann. Als Lead winken Sie Diffs dann entweder ungeprüft durch oder werden selbst zum Flaschenhals, und beides ist auf Dauer nicht tragbar.
Die Lösung: Maschinen fangen die mechanischen Probleme ab, und die menschliche Aufmerksamkeit bleibt für alles andere reserviert. Diese Checks lohnen sich unabhängig davon, wer den Code geschrieben hat. Dieser Artikel stellt drei davon vor, ergänzt zwei günstige Extras, zeigt, wie Sie alles in die CI einbinden, und endet mit dem, was keiner dieser Checks erkennen kann. Lint, Formatierung und Unit-Tests haben Sie bereits im Einsatz. Falls nicht, beginnen Sie mit diesem ESLint- und Prettier-Setup.
| Check | Was er erkennt | Was der Reviewer nicht mehr prüfen muss | Tool |
|---|---|---|---|
| Contract-Codegenerierung | Felder, die die API nie zurückgibt | Ob jedes Response-Feld existiert | Hey API openapi-ts |
| Schichtgrenzen | Imports, die Architekturschichten überschreiten | Ob jeder Import in die richtige Richtung zeigt | dependency-cruiser |
| Mutation Testing | Tests, die Code ausführen, ohne etwas darüber zu prüfen | Ob jeder neue Test tatsächlich Verhalten prüft | Stryker |
| Toter Code | Ungenutzte Dateien, Exports und Abhängigkeiten | Ob etwas verwaist ist | Knip |
| Duplikate | Per Copy-and-paste kopierte Codeblöcke | Ob diese Logik bereits existiert | jscpd |
Die wichtigsten Erkenntnisse
- Wird der TypeScript-Client aus einer OpenAPI-Spezifikation generiert, ist der Zugriff auf ein Feld, das die API nie zurückgibt, etwa
user.fullName, ein Compile-Fehler statt eines Review-Kommentars. - Eine
forbidden-Regel in dependency-cruiser mit Severityerrorlässt die CI fehlschlagen, wenn ein Service eine Komponente importiert. Mit demerr-long-Reporter erscheint dercommentder Regel neben jedem Verstoß und sagt dem Entwickler, wohin der Import gehört. - Line Coverage zeigt, dass Code ausgeführt wurde. Mutation Testing zeigt, ob ein Test fehlschlägt, wenn dieser Code absichtlich kaputt gemacht wird.
- Behandeln Sie den Mutation Score als Detektor und lassen Sie Strykers
thresholds.breakungesetzt, denn äquivalente Mutanten machen einen harten Schwellenwert irreführend. - Führen Sie neue Regeln zunächst als Warnungen für geänderte Dateien ein. Stufen Sie diejenigen, die echte Bugs finden, zu Fehlern hoch, und machen Sie einen Job erst dann zur Pflicht, wenn er sich bewährt hat.
API-Client aus der OpenAPI-Spezifikation generieren
Wird der TypeScript-Client aus einer OpenAPI-Spezifikation generiert, ist der Zugriff auf ein Feld, das die API nie zurückgibt, etwa user.fullName, ein Compile-Fehler statt eines Review-Kommentars. Ein handgeschriebenes Interface kann unbemerkt vom Backend abweichen. Ein generiertes nicht.
Ein handgeschriebener Typ kompiliert auch dann, wenn er falsch ist:
// Hand-written: compiles, but the backend never sends fullName
interface User { id: string; fullName: string }
declare const user: User;
const label = user.fullName;
Generieren Sie den Typ stattdessen aus der Spezifikation, schlägt derselbe Zugriff bereits beim Kompilieren fehl:
// Generated from the spec
type User = { id: string; firstName: string; lastName: string };
declare const user: User;
const label = user.fullName;
// error TS2339: Property 'fullName' does not exist on type 'User'.
const fixed = `${user.firstName} ${user.lastName}`;
Tools wie Hey API openapi-ts lesen ein OpenAPI-Dokument ein und erzeugen daraus Typen und einen Client. Sobald der Client generiert wird, reviewen Sie stattdessen die Spezifikation. Behandeln Sie den generierten Code als Build-Artefakt: Bearbeiten Sie ihn nicht von Hand und reviewen Sie ihn nicht Zeile für Zeile.
Typen verschwinden beim Kompilieren. Ergänzen Sie daher für Responses, denen Sie nicht blind vertrauen können, einen Laufzeit-Validator wie Zod. Nichts davon funktioniert ohne eine maßgebliche Spezifikation, die das Backend aktuell hält. Das komplette Setup beschreibt der Artikel Typsichere API-Clients mit OpenAPI und TypeScript erstellen.
Schichtgrenzen mit dependency-cruiser durchsetzen
dependency-cruiser prüft jeden Import in einem JavaScript- oder TypeScript-Projekt gegen die forbidden-Regeln in seiner Konfigurationsdatei. Verstöße werden ähnlich wie ESLint-Ausgaben angezeigt, und jeder Verstoß gegen eine Regel mit Severity error lässt den Befehl mit einem Exit-Code ungleich null enden, wodurch der CI-Job fehlschlägt. Damit verschwindet die Frage „Sollte dieser Service wirklich eine Komponente importieren?“ von der Liste des Reviewers.
npm install --save-dev dependency-cruiser
npx dependency-cruiser --init
--init erzeugt .dependency-cruiser.js bzw. .dependency-cruiser.cjs, wenn in package.json "type": "module" gesetzt ist. Die erzeugte Datei enthält bereits eine Reihe von Standardregeln. Sie melden Import-Zyklen, verwaiste Module, Pakete, die Sie importieren, aber nie in package.json eingetragen haben, sowie Produktionscode, der Dev-Dependencies einbindet. Ergänzen Sie dort Ihre Schichtregel:
module.exports = {
forbidden: [
{
name: "services-not-to-components",
comment:
"Services must not import UI. Move shared types to src/types or next to the service.",
severity: "error",
from: { path: "^src/services" },
to: { path: "^src/components" },
},
],
};
Formulieren Sie den comment als Handlungsanweisung, nicht als bloßes Label. Der Standard-Reporter err lässt ihn weg, der err-long-Reporter gibt ihn dagegen bei jedem Verstoß aus. Der Entwickler oder Agent, der den Fehler ausgelöst hat, liest dann die Lösung und nicht nur die Fehlermeldung:
npx dependency-cruiser --output-type err-long src
eslint-plugin-boundaries erledigt dieselbe Aufgabe innerhalb von ESLint, falls Sie das lieber in Ihrem Linter behalten möchten. oxlint bietet keine eingebaute Regel für Architekturschichten, aber seine Regel no-restricted-imports kann Imports blockieren, die einem Muster entsprechen.
Mit Mutation Testing prüfen, ob Tests Fehler erkennen
Mutation Testing prüft die Testqualität: Line Coverage zeigt, dass Code ausgeführt wurde, Mutation Testing dagegen, ob ein Test fehlschlägt, wenn dieser Code absichtlich verändert wird. Eine Test-Suite kann 100 % Coverage erreichen und trotzdem kaum etwas prüfen – genau das deckt Mutation Testing auf.
Stryker nimmt kleine Änderungen an Ihrem Quellcode vor; jede veränderte Version wird als Mutant bezeichnet. Anschließend führt Stryker Ihre Tests gegen jeden Mutanten aus. Ein Mutant gilt als getötet (killed), wenn mindestens ein Test gegen ihn fehlschlägt, und als überlebend (survived), wenn die gesamte Suite weiterhin grün ist.
Das Beispiel aus der Stryker-Dokumentation mutiert return user.age >= 18 auf vier Arten: > 18, < 18, return false und return true.
export function isUserOldEnough(user: { age: number }): boolean {
return user.age >= 18;
}
// Weak: 100% line coverage, two survivors
it("lets adults in", () => {
expect(isUserOldEnough({ age: 25 })).toBe(true);
});
// Added: boundary cases
it("lets in someone who is exactly 18", () => {
expect(isUserOldEnough({ age: 18 })).toBe(true);
});
it("keeps out someone who is 17", () => {
expect(isUserOldEnough({ age: 17 })).toBe(false);
});
Ein Test, der nur einen 25-Jährigen prüft, ist sowohl gegen den Mutanten > 18 als auch gegen den Mutanten return true grün. Beide überleben also, obwohl die Line Coverage bei 100 % liegt. Dieser Test tötet nur < 18 und return false. Der Fall mit Alter 18 tötet > 18, der Fall mit Alter 17 tötet return true. Mit allen drei Tests wird jeder Mutant getötet.
Überlebende Mutanten gehen meist auf einige wiedererkennbare Muster schwacher Tests zurück:
- Es wird geprüft, dass eine Funktion aufgerufen wurde, aber nicht, mit welchen Argumenten.
- Es wird geprüft, dass ein Ergebnis existiert oder wie lang es ist, ohne seinen Inhalt anzusehen.
- Ein Snapshot wird committet, den niemand mit der erwarteten Ausgabe abgeglichen hat.
- Der erwartete Wert wird mit derselben Logik berechnet wie im getesteten Code.
- Es wird geprüft, was ein Mock zurückgibt, statt was der Code damit macht.
Ein überlebender Mutant verweist auf Verhalten, das kein Test festschreibt. Manche Mutanten sind allerdings äquivalent zum ursprünglichen Code, weshalb der Mutation Score besser als Detektor denn als Bestanden/Nicht-bestanden-Schwelle taugt. Ein harter Schwellenwert belohnt außerdem Tests, die geschrieben werden, um Mutanten zu töten, statt Verhalten zu prüfen. Strykers thresholds.break lässt den Build unterhalb eines festgelegten Scores fehlschlagen. Der Standardwert ist null, und dabei sollten Sie es belassen. Sehen Sie sich stattdessen die Überlebenden im PR an.
Um die Laufzeiten kurz zu halten, mutieren Sie mit npx stryker run --mutate <comma-separated files> nur die Dateien, die ein PR geändert hat. Der inkrementelle Modus (--incremental) speichert die Ergebnisse des letzten Laufs in reports/stryker-incremental.json und verwendet wieder, was möglich ist. In der CI spart das allerdings nur Zeit, wenn die Datei zwischen den Läufen gecacht wird. Installieren Sie außerdem das Test-Runner-Plugin für Ihr Framework (@stryker-mutator/vitest-runner oder @stryker-mutator/jest-runner). Ohne Plugin greift Stryker auf seinen Command Runner zurück, der für jeden Mutanten die komplette Suite ausführt. Zur Ermessensfrage der Testqualität – also zu entscheiden, was überhaupt einen Test verdient – lesen Sie unseren Beitrag zum Testen im KI-Zeitalter.
Jeden Check als eigenen, verpflichtenden CI-Job ausführen
Ein Check, den man überspringen kann, wird nicht durchgesetzt. Geben Sie jedem Check einen eigenen Job, damit ein Fehlschlag auf einen Blick erkennbar ist, und markieren Sie dann jeden Job als Required Status Check in Ihrer Branch Protection oder Ihrem Ruleset. Falls GitHub Actions für Sie neu ist, beginnen Sie mit diesem Praxisleitfaden zu GitHub Actions.
on: pull_request
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npx eslint . && npx prettier --check .
- run: npx dependency-cruiser --output-type err-long src
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npx tsc --noEmit
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npm test
mutation:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with: { fetch-depth: 0 }
- uses: actions/setup-node@v7
with: { node-version: 22, cache: npm }
- run: npm ci
- name: Mutate changed files only
run: |
FILES=$(git diff --name-only --diff-filter=AMR "origin/${{ github.base_ref }}...HEAD" -- ':(glob)src/**/*.ts' ':(glob)src/**/*.tsx' \
| grep -vE '\.(test|spec)\.tsx?$' | paste -sd, -)
if [ -z "$FILES" ]; then echo "No source changes"; exit 0; fi
npx stryker run --mutate "$FILES"
build:
needs: [lint, typecheck, test, mutation]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npm run build
Der Mutation-Job lädt die vollständige Historie (fetch-depth: 0), damit er einen Diff gegen den Base-Branch erstellen kann. Anschließend übergibt er nur die geänderten Quelldateien an Stryker und lässt Testdateien außen vor.
Führen Sie neue Regeln schrittweise ein:
- Fügen Sie jede neue Regel als Warnung hinzu. In dependency-cruiser meldet
severity: "warn"Verstöße, lässt den Job aber nicht fehlschlagen. Das tut nurerror. - Führen Sie die Checks nur für geänderte Dateien aus.
- Stufen Sie eine Warnung zum Fehler hoch, sobald sie einen echten Bug findet, und löschen Sie Regeln, die nur Rauschen erzeugen.
- Machen Sie einen Job erst dann zur Pflicht, wenn er sich bewährt hat.
Zwei weitere günstige automatisierte Checks lohnen sich: Knip für toten Code und jscpd für Duplikate. Knip meldet Dateien, die nirgends importiert werden, Exports, die niemand nutzt, und Pakete, die Sie nicht mehr benötigen. jscpd markiert duplizierte Codeblöcke.
Was automatisierte Code-Review-Checks nicht leisten können
Deterministische Checks können Ihnen nicht sagen, ob das Feature das richtige ist, ob eine Abstraktion zu früh eingeführt wurde oder ob irgendjemand im Team den Code versteht. Das sind Ermessensentscheidungen, und dafür braucht es einen Menschen.
Für einen Pull Request, der jeden deterministischen Check besteht, ist lediglich nachgewiesen, dass er frei von den Defekten ist, nach denen diese Checks suchen. Zur menschlichen Seite des Reviews lesen Sie unseren Beitrag zum Testen im KI-Zeitalter.
Beginnen Sie mit dem Contract
Jede mechanische Frage, die Sie an eine Maschine abgeben, verschafft dem menschlichen Review Zeit für Fragen, die nur Menschen beantworten können. Wenn Sie in diesem Sprint nur einen einzigen Check einführen, generieren Sie Ihren API-Client aus der Spezifikation, denn damit verhindern Sie eine ganze Klasse von Defekten, bevor sie überhaupt jemand schreibt. Ergänzen Sie als Nächstes Mutation Testing auf dem PR-Diff als reinen Warn-Job, und sehen Sie sich an, was überlebt.
FAQs
Wie füge ich dependency-cruiser-Regeln zu einer bestehenden Codebasis hinzu, die bereits Hunderte von Verstößen aufweist?
Erfassen Sie die bestehenden Verstöße als Baseline und lassen Sie den Check nur bei neuen Verstößen fehlschlagen. Ab dependency-cruiser 18.3.0 ist dafür die Option --baseline der empfohlene Weg. Standardmäßig schreibt sie .dependency-cruiser-known-violations.json in das Projektverzeichnis, und ein Lauf mit --ignore-known stuft jeden dort aufgeführten Verstoß auf ignore herab. Jeder neue Import, der gegen dieselbe Regel verstößt, lässt den Check weiterhin fehlschlagen. Da der Abgleich die Severity ignoriert, bleiben die Baseline-Einträge einer Regel erhalten, wenn Sie deren Severity ändern.
Funktioniert Stryker Mutation Testing mit Vitest?
Ja. StrykerJS unterstützt Vitest seit Version 7.0 über das Plugin @stryker-mutator/vitest-runner; Jest-Projekte verwenden @stryker-mutator/jest-runner. Der Vitest-Runner nutzt immer die Coverage-Analyse perTest und ignoriert jede konfigurierte coverageAnalysis-Einstellung. Der Vitest Browser Mode wird nicht unterstützt. Stryker führt Tests pro Worker in einem einzigen Thread aus und parallelisiert stattdessen über seine eigenen Worker.
Was ist ein äquivalenter Mutant beim Mutation Testing?
Ein äquivalenter Mutant verändert den Quellcode, ohne das Verhalten des Programms zu ändern, sodass ihn kein Test jemals töten kann. Ein klassisches Beispiel ist das Ersetzen von i < length durch i != length in einer Schleife, die von null an in Einerschritten hochzählt: Beide Varianten enden an derselben Stelle. Da äquivalente Mutanten immer überleben, ist ein Mutation Score von 100 Prozent oft unerreichbar, und Überlebende müssen von einem Menschen gesichtet werden.
Was passiert, wenn Stryker auf geänderten Dateien ohne Tests läuft?
Standardmäßig bricht Stryker mit dem Fehler No tests found ab, wenn sein initialer Dry Run keine Tests ausführt. Dadurch schlägt ein CI-Job fehl, der nur den PR-Diff mutiert. Ab StrykerJS 7.0 verhindert das Flag --allowEmpty (oder allowEmpty: true in der Konfigurationsdatei), dass dies als Fehler gewertet wird. Mutanten in Code, den kein Test ausführt, werden als ohne Coverage gemeldet, was direkt auf die fehlenden Tests hinweist.