GitLab CI mit Testreports einrichten
Schnellere .NET-CI-Pipelines mit Test Sharding, Teil 3
Der vorangegangene Teil 2 dieser Serie zum Thema „Schnellere .NET-CI-Pipelines mit Test Sharding“ hat die als Beispiel gewählte Suite mit einer GitHub-Matrix auf vier Jobs verteilt und deren TRX-Berichte gesichert. In GitLab bleibt das Grundprinzip gleich: Alle Jobs entdecken dieselben Tests, wählen aber unterschiedliche Teilmengen. GitLab vereinfacht dabei die Nummerierung, bei Ergebnisformaten und gemeinsam genutzten Diensten sind dagegen zusätzliche Schritte nötig.
Shard-Nummern aus der CI übernehmen
Mit parallel: 4 erzeugt GitLab vier Instanzen eines Jobs. CI_NODE_INDEX zählt dabei von eins bis vier, CI_NODE_TOTAL enthält die Gesamtzahl. ShardedTest 1.1.0 verwendet diese Variablen, sofern die entsprechenden Kommandozeilenoptionen fehlen. Explizite Optionen haben Vorrang. Weil GitLab und ShardedTest beide ab eins zählen, wäre eine zusätzliche Umrechnung auf nullbasierte Indizes hier falsch.
Das Beispiel führt dasselbe .NET-10-/MTP-Projekt wie in den ersten beiden Teilen dieser Serie aus. Der Runner benötigt einen Docker- oder Kubernetes-Executor, der das angegebene Linux-Containerimage verwendet. Bei einem Shell-Executor installiert der Betreiber das SDK selbst, denn ein image-Eintrag richtet dessen Host nicht ein.
Ergebnisformat vor dem Workflow klären
Das Testprojekt erzeugt TRX-Dateien. GitLabs Testansicht erwartet dagegen JUnit-XML. Beide Formate beschreiben Testergebnisse, unterscheiden sich aber in ihrer XML-Struktur. Eine TRX-Datei unter artifacts:reports:junit anzugeben oder in .xml umzubenennen genügt deshalb nicht. Das lokale .NET-Tool trx2junit wandelt die vorhandenen Reports um. Dazu ergänzen wir im Repository-Hauptverzeichnis einmalig das Toolmanifest aus Teil 1 dieser Serie:
dotnet tool install trx2junit --version 2.1.0
Das geänderte Manifest gehört ins Repository, damit dotnet tool restore in jedem Job auch den Konverter installiert. trx2junit 2.1.0 zielt auf eine ältere .NET-Runtime, die der SDK-10-Container nicht automatisch enthält. Das nachfolgende Listing setzt deshalb DOTNET_ROLL_FORWARD=Major, und zwar nur für den Konvertierungsaufruf: Die Variable erlaubt ihm eine neuere Runtime-Hauptversion. Alternativ lässt sich die passende ältere Runtime zusätzlich installieren.
Microsoft bietet inzwischen auch eine JUnit-Erweiterung für MTP an, kennzeichnet sie aber als experimentell. Diese Artikelserie bleibt beim TRX-Konverter. Damit dienen dieselben Rohberichte der GitLab-Anzeige und der späteren Auswertung.
Vier Jobs mit getrennten Dateien
Die Datei .gitlab-ci.yml liegt im Repository-Hauptverzeichnis, ihr Inhalt lautet:
stages: [test]
sharded_tests:
stage: test
image: mcr.microsoft.com/dotnet/sdk:10.0
parallel: 4
timeout: 20m
variables:
TEST_PROJECT: tests/ShardDemo.Tests/ShardDemo.Tests.csproj
before_script:
- dotnet tool restore
- dotnet restore "$TEST_PROJECT"
- dotnet build "$TEST_PROJECT" -c Release --no-restore
script:
- >-
dotnet tool run sharded-test
--project "$TEST_PROJECT"
--configuration Release --no-build
--results-directory "$CI_PROJECT_DIR/results/$CI_NODE_INDEX"
--report-trx
--report-trx-filename 'tests_{pid}_{time}.trx'
after_script:
- |
for report in "$CI_PROJECT_DIR/results/$CI_NODE_INDEX/"*.trx; do
[ -f "$report" ] || continue
DOTNET_ROLL_FORWARD=Major dotnet tool run trx2junit "$report"
done
artifacts:
when: always
name: "tests-$CI_JOB_ID"
paths:
- results/
reports:
junit: results/**/*.xml
expire_in: 7 days
before_script stellt Werkzeuge und Pakete wieder her und baut das Projekt. script startet den Shard; Index und Gesamtzahl liest das Tool aus GitLabs Umgebungsvariablen. >- verbindet die eingerückten YAML-Zeilen zu einem Shell-Befehl. after_script konvertiert anschließend die Berichte, bevor GitLab die Artefakte hochlädt.
Jeder Shard schreibt in einen eigenen Ordner unter results. Die TRX-Dateinamen enthalten Prozess-ID und Zeitstempel, damit mehrere Filterbatches einander nicht überschreiben. Die Schleife verarbeitet jede vorhandene TRX-Datei. [ -f "$report" ] überspringt das Suchmuster, falls keine Datei passt. trx2junit schreibt die XML-Datei mit gleichem Basisnamen daneben. paths bewahrt beide Formate zum Download auf, und reports:junit meldet die XML-Dateien zusätzlich für GitLabs Testansicht an.
Das Artefakt trägt die eindeutige GitLab-Job-ID. Das Container-Tag 10.0 folgt dagegen SDK-Aktualisierungen. Für eine Messreihe fixiert das Team einen Image-Digest, also die Kennung eines konkreten Image-Inhalts, und notiert den enthaltenen SDK-Patch. So verändert kein zwischenzeitliches Image-Update unbemerkt den Vergleich.
Reports verändern den Teststatus nicht
after_script läuft bei normalen Testfehlern nach dem Hauptskript und vor dem Artefakt-Upload. Der Konverter kann daher auch fehlgeschlagene Tests für GitLab aufbereiten. Der Abschnitt startet allerdings in einer neuen Shell: YAML-Variablen und Toolmanifest bleiben verfügbar, nur im Hauptskript exportierte Shell-Variablen nicht. Bei einem Job-Time-out läuft after_script standardmäßig nicht: Auch when: always ist keine Garantie für vollständige Reports nach einem Abbruch.
GitLab übernimmt einen Fehler aus after_script nicht als neuen Jobfehler, wenn das Hauptskript erfolgreich war. Das Listing garantiert folglich keine Reportvollständigkeit. Soll ein fehlender oder unlesbarer Bericht den Merge blockieren, braucht es einen verpflichtenden Prüfschritt. Er kontrolliert nach dem Einsammeln aller Artefakte die erwarteten Shards und deren Ergebnisse.
Der Testaufruf bleibt für den Jobstatus entscheidend. || true würde seinen Fehlercode überdecken; allow_failure: true würde einen gescheiterten Job für die Pipeline tolerieren. Beides fehlt bewusst im Listing. JUnit liefert nur die Darstellung der Testergebnisse: GitLab setzt den Jobstatus nicht anhand der Assertions im hochgeladenen XML
Integrationstests voneinander trennen
Getrennte Container isolieren ihre lokalen Dateisysteme. Eine gemeinsam angesprochene Datenbank oder ein externes API bleiben trotzdem gemeinsam. Erzeugt jeder Shard einen Benutzer mit derselben E-Mail-Adresse, können sich die Tests gegenseitig stören. Besonders problematisch sind globale Löschoperationen im Teardown: Ein Job entfernt dann möglicherweise Daten, die ein anderer gerade prüft.
Für Tests mit externem Zustand eignet sich ein Namensraum pro Job. Als Grundlage dient die Job-ID: Sie unterscheidet auch gleichzeitig laufende Pipelines und erneut gestartete Jobs. Eine zusätzliche ID pro Test grenzt parallel laufende Fälle innerhalb desselben Shards ab. Beispielsweise erzeugt der Testkörper ein Ressourcenpräfix:
var job = System.Environment.GetEnvironmentVariable("CI_JOB_ID")
?? System.Guid.NewGuid().ToString("N");
var resourcePrefix = $"ci_{job}_{System.Guid.NewGuid():N}";
Das Präfix gehört in Testdaten und Ressourcenbezeichnungen, soweit der Dienst deren Länge und Zeichen erlaubt. Ein Teardown löscht anschließend nur Ressourcen mit diesem Präfix. In die Anzeigenamen der Discovery gehört es dagegen nicht: Zufallswerte im Testkatalog könnten jedem Shard eine andere sortierte Liste liefern. Der Testfall behält deshalb seinen stabilen Namen, während seine Laufzeitressourcen unabhängig sind.
Wo Namensräume nicht reichen, erhält jeder Job eine eigene Datenbank. Das erhöht Startaufwand und Ressourcenbedarf. Auch Fixtures, also gemeinsam genutzte Vorbereitungen und Ressourcen einer Testgruppe, entstehen in getrennten Testprozessen erneut. Besonders teure Fixtures können deshalb eine separate Testsuite rechtfertigen.
Runner und Cache begrenzen den Gewinn
parallel: 4 garantiert vier Jobinstanzen, nicht vier gleichzeitig verfügbare Maschinen. Ein knapp konfigurierter Runner-Pool lässt Jobs warten. Erreichen vier Jobs dieselbe ausgelastete Datenbank, steigt womöglich sogar die Laufzeit jedes Shards.
Ein NuGet-Cache kann den wiederholten Restore verbilligen. Sein Schlüssel sollte relevante Lockdateien und die Umgebung berücksichtigen. Bei vielen gleichartigen Jobs vermeidet es konkurrierende Uploads, wenn nur ein Job den Cache schreibt und die Shards ihn lediglich lesen. GitLab unterscheidet dafür die Cache-Policies pull, push und pull-push. Ergebnisreports bleiben Artefakte eines konkreten Laufs und gehören nicht in diesen Cache.
Zwischenfazit: Parallelität braucht getrennte Ressourcen
GitLab übernimmt die Shard-Nummerierung und zeigt die konvertierten Ergebnisse an. Verlässliche Parallelläufe setzen zusätzlich unabhängige Testdaten und eine Prüfung fehlender Reports voraus. Vier Jobs bilden dabei nur einen Ausgangspunkt. Ob ihr Laufzeitgewinn die zusätzlichen Runner-Minuten rechtfertigt, wird der abschließende Teil dieser Serie mit einem systematischen Vergleich klären.