Zum Inhalt springen

Tests mit Coverage-Messung und Mindest-Deckung als Gate

Tests mit Coverage-Messung ausführen und gegen eine Mindest-Line-Coverage prüfen, konfiguriert in .codecharter/config.yml, berichtet als JSON.

codecharter coverage [root] [optionen]

codecharter coverage sucht die Testprojekte unterhalb von root (standardmäßig das aktuelle Verzeichnis), führt sie mit Coverage-Messung aus, führt die Ergebnisse zusammen und lässt den Build fehlschlagen, sobald die Line-Coverage unter den geforderten Mindestwert fällt. Liegt genau eine Solution-Datei im Root, wird sie einmal vorab gebaut; die Testprojekte laufen danach ohne erneuten Build und mehrere gleichzeitig. Derselbe Lauf meldet zusätzlich jede ungedeckte Region mit Quelltext-Ausschnitt, sodass du genau siehst, welchen Zeilen ein Test fehlt.

Damit ist die zweite Hälfte des Quality Gates abgedeckt: analyze beantwortet "ist der Code sauber geschrieben", coverage beantwortet "ist er tatsächlich getestet".

Voraussetzung

Die Messung läuft über den Collector XPlat Code Coverage, der das Paket coverlet.collector in jedem Testprojekt benötigt:

<PackageReference Include="coverlet.collector" Version="6.*" PrivateAssets="all" />

Ein Testprojekt ohne dieses Paket führt seine Tests zwar aus, liefert aber keine Coverage-Daten. Der Lauf benennt jedes betroffene Projekt und gibt die Zeile aus, die du ergänzen musst.

Schnellstart

# Repository gegen den konfigurierten Mindestwert prüfen
codecharter coverage

# Einen Ordner gegen 90 % prüfen
codecharter coverage src --min-coverage 90

# Ergebnisse eines bereits gelaufenen Testlaufs auswerten
codecharter coverage --skip-tests

# Report in eine Datei statt nach stdout schreiben
codecharter coverage --output-file coverage-report.json

Während der Lauf arbeitet, schreibt er seinen Fortschritt nach stderr, sodass auch ein langer Lauf nie stumm ist: die Anzahl gefundener Testprojekte, den gemeinsamen Build mit Dauer und jedes Testprojekt beim Start und beim Ende mit Dauer und Ergebnis. Der JSON-Report geht nach stdout, Diagnosen und eine einzeilige Zusammenfassung nach stderr. Durch diese Trennung bleibt codecharter coverage > report.json in einer Pipeline direkt verwendbar, ohne dass du etwas herausfiltern musst.

Optionen

Option Standard Beschreibung
[root] aktuelles Verzeichnis Verzeichnisbaum, der nach Testprojekten durchsucht wird.
--min-coverage <prozent> aus der Konfiguration Geforderte Mindest-Line-Coverage (0 bis 100). Überschreibt den konfigurierten Wert für diesen Lauf. Die Zahl wird gebietsschema-unabhängig gelesen, 99.5 ist also überall korrekt, 99,5 nicht.
--git-ref <bereich> aus Statt des gesamten Repositories die Zeilen prüfen, die dieser Git-Bereich geändert hat. Siehe Diff-Coverage.
--affected-by <bereich> aus Statt aller gefundenen Testprojekte nur die Testprojekte ausführen, die dieser Git-Bereich betrifft. Setzt denselben Bereich wie --git-ref voraus. Siehe Testauswahl eingrenzen mit --affected-by.
--min-diff-coverage <prozent> der wirksame --min-coverage-Wert Geforderte Mindest-Coverage der geänderten Zeilen (0 bis 100). Setzt --git-ref voraus. Wird gebietsschema-unabhängig gelesen wie --min-coverage.
--results-root <verzeichnis> <root>/TestResults/coverage Ablage für Test- und Coverage-Artefakte. Ein relativer Pfad wird gegen das aktuelle Arbeitsverzeichnis aufgelöst, nicht gegen root.
--skip-tests aus Tests nicht ausführen, sondern die bereits vorhandenen Coverage-Dateien unterhalb des Results-Root auswerten.
--max-parallel <n> Prozessoranzahl, gedeckelt bei 4 Wie viele Testprojekte nach dem gemeinsamen Build gleichzeitig laufen (1 oder mehr). Überschreibt den konfigurierten Wert für diesen Lauf. Mit 1 laufen sie nacheinander, was einen echten Fehlschlag von zwei Projekten unterscheidbar macht, die sich um denselben Port, dieselbe Pipe oder dieselbe Datei streiten.
--output-file <pfad> stdout JSON-Report in diese Datei schreiben.
--test-timeout <sekunden> 1800 (30 Minuten) Wie lange ein einzelnes Testprojekt laufen darf. Läuft die Zeit ab, werden der Testprozess und alles, was er gestartet hat, beendet. Das Projekt wird mit genau diesem Grund als fehlgeschlagen berichtet, der Lauf macht mit den übrigen Projekten weiter. Die Grenze gilt je Projekt, nicht für den ganzen Lauf.
--findings aus Jede ungedeckte Region zusätzlich als Finding ausgeben, gerendert über dieselben Formatter wie bei analyze.
--findings-format <name> console Format für --findings: console, json, sarif oder github-annotations.
--findings-file <pfad> stderr Ausgabe von --findings in diese Datei schreiben.
--verbose aus Zusätzlich informative Fortschrittszeilen nach stderr ausgeben.
--no-color aus ANSI-Farbcodes abschalten.

--test-timeout gibt es nur auf der Kommandozeile und nicht als Konfigurationsschlüssel: Der passende Wert richtet sich nach der Maschine und nach dem Zeitlimit des CI-Jobs, nicht nach dem Repository. Ein abgelaufenes Projekt wird nie wiederholt, denn ein Hänger wiederholt sich und ein zweiter Versuch würde nur dieselbe Zeit noch einmal verbrauchen.

Solange ein Lauf arbeitet, gehört ihm sein Results-Root. Ein zweiter Lauf auf demselben Results-Root wartet auf den ersten und sagt das auch auf stderr, denn sonst würden beide dieselben Verzeichnisse je Projekt leeren und neu schreiben und am Ende beide eine Mischung auswerten. Das Warten ist auf zehn Minuten begrenzt. Danach bricht der Lauf mit Exit-Code 64 ab, statt eine Zahl zu liefern, der niemand trauen kann. Eine Sperre, die ein abgebrochener Lauf zurückgelassen hat, wird erkannt und entfernt, ein abgebrochener CI-Job blockiert den nächsten also nicht.

--skip-tests prüft vor der Auswertung, ob zu jedem gefundenen Testprojekt eine Coverage-Datei vorliegt. Ein veralteter oder abgebrochener Lauf kann das Gate so nicht unbemerkt passieren. Stammt das Ergebnisverzeichnis von einem anderen Werkzeug und trägt nicht die Ablage je Projekt, fällt die Prüfung auf einen Abgleich der Anzahlen zurück und weist darauf hin.

Konfiguration

Die Policy steht in .codecharter/config.yml, wird also einmal eingecheckt und gilt für alle:

coverage:
  minimum-percent: 99.5           # Schwelle des Gates (0 bis 100), Standard 100
  snippet-context-lines: 3        # Kontextzeilen um jede ungedeckte Region
  max-parallel-test-projects: 4   # gleichzeitig laufende Testprojekte, Standard: Kerne, gedeckelt bei 4
  exclude:                        # von der Messung ausgenommene Dateien (Globs)
    - "**/*.Designer.cs"
    - "src/Generated/**"
  excluded-directories:           # bei der Suche übersprungene Verzeichnisbäume (Globs)
    - "vendor/**"

Alle fünf Schlüssel gelten repositoryweit. Anders als die Analyse-Sektionen lässt sich coverage nicht pro Pfad-Scope variieren. Globs werden groß-/kleinschreibungsgenau gegen Pfade relativ zum Lauf-Root abgeglichen, geschrieben mit Schrägstrichen (**/ überspannt Verzeichnisse, * bleibt innerhalb eines Pfadsegments).

exclude und excluded-directories

Die beiden Listen sehen sich ähnlich, tun aber sehr Verschiedenes:

  • exclude wirkt zum Zeitpunkt der Messung. Die Tests laufen weiterhin, die passenden Quelldateien zählen nur nicht in die Prozentzahl und erzeugen keine ungedeckten Regionen. Gedacht für generierten Code, der sich nicht sinnvoll testen lässt.
  • excluded-directories wirkt zum Zeitpunkt der Suche. Ein passender Verzeichnisbaum wird gar nicht erst betreten, ein Testprojekt darin also nie gefunden und nie ausgeführt. Gedacht für einen mitgelieferten Fremdbaum, dessen Tests dein Repository nichts angehen.

Weil die Suche den ganzen Baum abschneidet, sobald das Verzeichnis selbst passt, bedeuten vendor, vendor/ und vendor/** dasselbe. Mit **/third-party triffst du ein so benanntes Verzeichnis in jeder Tiefe. Die Verzeichnisnamen bin, obj, node_modules und .git werden immer übersprungen und brauchen keinen Eintrag.

Beachte, dass coverage.exclude unabhängig vom übergeordneten exclude der Analyse ist. Ein von der Regelanalyse ausgenommener Ordner verschwindet also nicht stillschweigend aus deiner Coverage-Zahl, und umgekehrt.

Die wirksame Schwelle wird in dieser Reihenfolge bestimmt:

  1. --min-coverage auf der Kommandozeile
  2. .codecharter/config.local.yml (dein persönliches, maschinenlokales Overlay)
  3. .codecharter/config.yml (die eingecheckte Team-Einstellung)
  4. der eingebaute Standardwert 100

Der Report nennt, welche der vier Quellen gewonnen hat, sodass sich ein überraschendes Gate-Ergebnis immer zurückverfolgen lässt. max-parallel-test-projects folgt derselben Reihenfolge, mit --max-parallel an erster Stelle, führt aber keine Herkunft mit: nur die Schwelle nennt ihre Quelle. Eine coverage.exclude- oder coverage.excluded-directories-Liste im lokalen Overlay ersetzt dabei die eingecheckte Liste, sie ergänzt sie nicht.

All das lässt sich auch von der Kommandozeile aus pflegen statt von Hand, siehe codecharter config.

Was in die Zahl einfließt

Positional Records, die nur Daten tragen, enthalten kein Verhalten, das sich zu testen lohnt, und bleiben deshalb außerhalb der Messung. Ein Record gilt so lange als reiner Datenträger, wie kein Mitglied einen selbst geschriebenen Rumpf hat: Auto-Properties (auch mit init) und der generierte Primärkonstruktor erhalten diesen Status, während eine berechnete Property, ein prüfender Konstruktor, ein Methodenrumpf, ein Operator, ein Indexer oder ein Event den gesamten Record wieder in das Gate holen.

Klassen und Structs werden auf diesem Weg nie ausgenommen. Die Ausnahme ist bewusst auf Records beschränkt, weil nur ein Record den Vertrag "Daten, keine Logik" ausdrücklich erklärt.

Alles Weitere nimmst du selbst aus, über coverage.exclude.

Diff-Coverage

--git-ref <bereich> schaltet das Gate vom gesamten Repository auf die Zeilen um, die dieser Bereich geändert hat:

codecharter coverage --git-ref origin/main..HEAD

Der Bereich verwendet dieselbe Syntax wie analyze --git-ref und wird an git diff <bereich> --unified=0 übergeben. Jede hinzugefügte oder geänderte Zeile der neuen Seite des Diffs wird mit der zusammengeführten Zeilen-Coverage des Laufs geschnitten. Die Diff-Coverage ist dann der Anteil der gedeckten messbaren geänderten Zeilen an allen messbaren geänderten Zeilen. Messbar ist eine geänderte Zeile, wenn die Coverage-Daten etwas über sie aussagen. Ein Kommentar, eine Leerzeile, eine Datei ohne Coverage-Daten und eine über coverage.exclude ausgenommene Datei ändern daran nichts und zählen nirgends mit. Umbenannte Dateien werden unter ihrem neuen Pfad geführt.

Das ist das Gate, das ein Pull-Request braucht. Ein Repository bei 62 Prozent muss nicht erst auf 100 klettern, um zu verlangen, dass neuer Code getestet ankommt. Jede Änderung prüft sich selbst, und die Gesamtzahl steigt von allein.

Die Gesamt-Coverage wird weiterhin gemessen und genau wie bisher berichtet, sie entscheidet nur nicht mehr über das Ergebnis. Die Schwelle des Diff-Gates ist --min-diff-coverage, wenn du es angibst, und andernfalls der wirksame --min-coverage-Wert, so wie ihn die Konfigurationsreihenfolge aufgelöst hat. Der Report nennt, welcher der beiden Fälle gegolten hat.

Ein Diff, der überhaupt keine messbare Zeile geändert hat, etwa ein reiner Doku-Commit oder eine Umbenennung, passiert das Gate und sagt das ausdrücklich: der berichtete Prozentwert ist null, nie eine erfundene 100.

Für die Diff-Schwelle gibt es bewusst noch keinen Konfigurationsschlüssel. Ein Git-Bereich ist nur je Aufruf sinnvoll, und --git-ref gehört in die Pipeline-Definition, nicht in eine eingecheckte Datei.

In einer Pull-Request-Pipeline

- uses: actions/checkout@v4
  with:
    fetch-depth: 0   # der Basis-Commit muss im Clone liegen
- name: Changed-lines coverage gate
  run: codecharter coverage --git-ref origin/main..HEAD --min-diff-coverage 90

fetch-depth: 0 ist der eine Punkt, den du richtig setzen musst. Ein CI-Checkout ist standardmäßig flach, dann fehlt der Basis-Commit und der Bereich lässt sich nicht auflösen. Das ist ein Aufruffehler (Exit-Code 64) und nie ein stiller Erfolg; die Meldung sagt das auch so.

Testauswahl eingrenzen mit --affected-by

--git-ref grenzt ein, welche Zeilen das Gate betreffen; --affected-by grenzt ein, welche Testprojekte überhaupt laufen:

codecharter coverage --git-ref origin/main..HEAD --affected-by origin/main..HEAD

--affected-by muss denselben Bereich wie --git-ref benennen: Beide Optionen beschreiben dieselbe Änderung aus zwei Blickwinkeln, und eine Abweichung zwischen beiden wird abgewiesen, bevor überhaupt etwas läuft. Ausgehend vom Bereich verfolgt der Befehl den Projektverweisgraphen rückwärts von jedem Projekt, zu dem eine geänderte Datei gehört, und führt nur die Testprojekte aus, die dieser Graph erreicht, statt aller von der Suche gefundenen. Auf einer großen Solution, bei der ein Pull-Request typischerweise nur eine Handvoll Projekte berührt, wird aus einem Coverage-Lauf, der sonst alles baut und testet, einer, der nur ausführt, was die Änderung überhaupt betreffen kann.

Eine geänderte Datei, die sich keinem Projekt zuordnen lässt, oder die zu einer Datei gehört, von der die gesamte Solution abhängt, etwa Directory.Build.props, eine .sln, .codecharter/config.yml oder alles unter build/, .github/workflows/ oder tools/, weitet den Lauf automatisch wieder auf alle gefundenen Testprojekte aus. Eine Änderung, die alles betreffen könnte, wird also so behandelt, als beträfe sie alles, statt sie stillschweigend zu unterprüfen.

Drei Aufruffehler, alle mit Exit-Code 64:

  • --affected-by ohne --git-ref.
  • --affected-by mit einem anderen Bereich als --git-ref.
  • --affected-by zusammen mit --skip-tests: Beide Optionen beantworten dieselbe Frage ("welche Projekte zählen") aus unvereinbaren Quellen, einer bereits vorhandenen Coverage-Datei oder einem frisch berechneten Projektabschluss.

Das Gate bleibt auch bei einer engeren Testauswahl fail-closed. Nach dem Lauf wird jede vom Bereich geänderte Datei gegen das tatsächlich Gemessene geprüft. Hat eine geänderte Datei überhaupt keine Coverage-Daten, weil die Tests des zugehörigen Projekts nicht ausgewählt wurden oder weil die Datei tatsächlich ungetestet ist, schlägt der Lauf mit Exit-Code 3 fehl und nennt die Datei, genau wie bei einem Lauf ohne jede Messung. --affected-by kann einen Lauf nur schneller machen, niemals eine Lücke in der Coverage wie einen Erfolg aussehen lassen.

Schlägt die Prüfung fehl, benennt die Meldung auf stderr die Datei direkt (die Meldung bleibt auf Englisch):

--affected-by completeness check failed: changed file '...' has no coverage data at all. Its owning project's tests were not selected, or the file is genuinely untested; either way this suite selection cannot gate it. Run without --affected-by, or widen the selected suites.

Dabei gilt derselbe Exit-Code 3, den ein Lauf ohne jede Coverage-Daten ergäbe.

Stammt das Ergebnisverzeichnis von einem einfachen dotnet test --results-directory statt von diesem Befehl (derselbe flache Aufbau, bei dem --skip-tests auf einen Abgleich der Anzahlen zurückfällt), braucht die Vollständigkeitsprüfung für --affected-by einen projektweisen Aufbau: In einem flachen Aufbau sieht die Coverage eines ausgewählten Projekts genauso aus wie eine veraltete Datei, die ein übersprungenes Projekt aus einem früheren Lauf hinterlassen hat. Statt zu raten, wird die Prüfung mit einer Warnung auf stderr übersprungen, und das Gate verlässt sich auf die Testauswahl.

Exit-Codes

Code Bedeutung
0 Die Coverage hat den geforderten Mindestwert erreicht.
1 Die Coverage liegt unter dem geforderten Mindestwert.
2 Tests sind fehlgeschlagen oder die Coverage-Daten waren unvollständig.
3 Überhaupt keine Coverage-Daten.
64 Fehler in Aufruf, Konfiguration oder Umgebung (etwa ein fehlendes .NET SDK oder ein nicht beschreibbares --output-file).

Mit --affected-by ist eine widersprüchliche oder unvereinbare Kombination von Optionen (siehe Testauswahl eingrenzen mit --affected-by) ein Aufruffehler (64), und eine fehlgeschlagene Vollständigkeitsprüfung nach dem Lauf wird als 3 gemeldet, genau wie jeder andere Lauf ohne Messung für eine Datei, auf die es ankommt.

Mit --git-ref behalten die Codes ihre Bedeutung und gelten für das Diff-Gate: 1 heißt, die geänderten Zeilen haben ihren Mindestwert verfehlt, und 64 deckt neben den übrigen Aufruffehlern auch einen nicht auflösbaren Git-Bereich ab.

Das Gate ist fail-closed: fehlende, leere oder unlesbare Coverage-Daten führen nie zu einem Erfolg. Exit-Code 3 bedeutet "es wurde nichts gemessen" und erscheint niemals als grüne 100 %.

Die angezeigte Prozentzahl wird abgerundet. 100.00 % heißt also, dass jede messbare Zeile gedeckt ist, und sonst nichts. Eine einzige ungedeckte Zeile in einer großen Solution erscheint als 99.99 % und lässt ein Gate mit 100 % fehlschlagen, statt sich zu einem falschen Erfolg aufzurunden.

Der Report

Der JSON-Report besteht aus vier Teilen:

  • summary: messbare Zeilen, gedeckte Zeilen, der Prozentwert, der geforderte Mindestwert, dessen Herkunft, das Gate-Ergebnis und das Alter der ausgewerteten Coverage-Dateien.
  • testResults: ein Eintrag je Testprojekt mit dessen Exit-Code und der Anzahl der ausgeführten Tests: total, passed, failed und skipped. Diese vier Werte sind null, wenn das Projekt keine auswertbaren Testergebnisse hinterlassen hat, etwa nach einem mitten im Lauf abgestürzten Test-Host. So sieht "nicht gemessen" nie wie ein Lauf ohne Tests aus. Die Liste insgesamt ist leer, wenn du --skip-tests verwendest.
  • uncoveredRegions: jeder zusammenhängende Block nie ausgeführter Zeilen, mit Datei (als absoluter Pfad und relativ zum Lauf-Root), umgebender Methode, Zeilennummern und Quelltext-Ausschnitt. Eine Region endet außerdem an der Methodengrenze: Ein Block, der vom Ende einer Methode in die nächste läuft, erscheint als zwei Regionen, jede der Methode zugeordnet, in der sie tatsächlich liegt.
  • diffCoverage: null, solange der Lauf nicht mit --git-ref eingegrenzt wurde. Andernfalls der Git-Bereich, measurableChangedLines, coveredChangedLines, der percent-Wert (null, wenn nichts Messbares geändert wurde), der requiredPercent mit seiner Herkunft thresholdProvenance (--min-diff-coverage oder inherited), das Gate-Ergebnis met und uncoveredChangedRegions, also die ungedeckten Regionen eingeschränkt auf die geänderten Zeilen, in derselben Form wie uncoveredRegions.

Der relative Pfad ist der stabile Schlüssel für CI-Annotationen, denn er hängt nicht davon ab, wohin der Build-Agent das Repository ausgecheckt hat.

Ungedeckte Regionen als Findings

Mit --findings werden dieselben ungedeckten Regionen zusätzlich als Findings gemeldet, in genau der Form, die analyze erzeugt, und über dieselben Formatter gerendert. Jedes Finding trägt die Regel-ID coverage/uncovered-region, eine Meldung mit Methode und Zeilenbereich sowie als Anker den repo-relativen Dateipfad und die erste Zeile der Region. Die Schwere folgt dem Gate: informativ, solange der Lauf seine Schwelle noch erreicht, und ein Fehler, sobald er sie verfehlt. Mit --git-ref folgt die Schwere dem Diff-Gate, und nur eine Region, die tatsächlich eine geänderte Zeile enthält, kann den Fehler tragen. Jede andere Region bleibt informativ, damit ein kleiner Pull-Request nie eine ganze gewachsene Codebasis rot färbt.

# Ungedeckte Zeilen an einem GitHub-Pull-Request annotieren
codecharter coverage --output-file coverage-report.json \
  --findings --findings-format github-annotations

Der Schalter ist rein additiv. Der JSON-Report bleibt die Standardausgabe auf stdout und ändert sich nicht, und der Exit-Code ist exakt derselbe wie ohne den Schalter. Die Findings gehen nach stderr, solange du mit --findings-file kein Ziel angibst, damit stdout weiterhin den Report trägt. In der Ausgabe von codecharter analyze tauchen Coverage-Findings nie auf, die beiden Gates bleiben getrennt.

In der CI

- name: Coverage gate
  run: codecharter coverage --output-file coverage-report.json
  # Exit-Code 1 färbt den Schritt rot, sobald die Coverage unter den Mindestwert fällt.

Da die Schwelle in der Konfigurationsdatei steht, muss die Pipeline-Definition nicht angefasst werden, wenn das Team die Latte höher legt.

Mit einem KI-Assistenten

Dasselbe Gate steht KI-Coding-Assistenten als Tool run_coverage des MCP-Servers zur Verfügung. Der Assistent erhält die ungedeckten Regionen samt Datei, Methode, Zeilennummern und Ausschnitt, also alles, was er braucht, um die fehlenden Tests zu schreiben, ohne vorher danach zu suchen. Zusätzlich enthält das Tool-Ergebnis ein Array findings mit denselben Regionen in der Form, die auch die analyze_*-Tools liefern. Ein Assistent kann eine Coverage-Lücke damit genauso behandeln wie einen Regelverstoß. Das Tool nimmt zusätzlich den optionalen Parameter git_ref mit derselben Bedeutung wie --git-ref und liefert dann die Sektion diffCoverage mit, sodass ein Assistent genau die Zeilen abarbeiten kann, die seine eigene Änderung ungetestet gelassen hat. Es nimmt außerdem den Parameter affected_by mit derselben Bedeutung und denselben Regeln wie --affected-by entgegen, und sein Ergebnis trägt, anders als der Report auf stdout der Kommandozeile, ein Objekt coverageScope, das genau benennt, welche Testsuiten ausgewählt und welche übersprungen wurden, und bei einer fehlgeschlagenen Vollständigkeitsprüfung, welche geänderte Datei die Ursache war.

Weiterführend