Tests gezielt verteilen
Schnellere .NET-CI-Pipelines mit Test Sharding, Teil 1
Automatisierte Tests prüfen Änderungen bei jedem Build. Mit der Suite wächst allerdings die Zeit, die ein Pull Request auf die Testergebnisse wartet. Test Sharding verteilt diese Arbeit auf mehrere Continuous-Integration-Jobs (CI-Jobs). Die Testauswahl übernimmt beispielsweise die Bibliothek Meziantou.ShardedTest – ein .NET-Kommandozeilentool, das den Anwendungscode aufruft. Dieser Beitrag erklärt das Verfahren und schafft die Projektgrundlage für GitHub Actions, GitLab CI und den abschließenden Laufzeitvergleich.
Wo die Zeit vergeht
Ein CI-Job lädt meist Quellcode und Pakete, baut das Projekt, ermittelt die Testfälle, die sogenannte Discovery, und führt sie aus. Die Maschine beziehungsweise Ausführungsumgebung, die den Job abarbeitet, heißt Runner. Welcher dieser Schritte die Laufzeit bestimmt, entscheidet über den Nutzen von Sharding: Dominiert der Build, löst eine andere Testverteilung das Hauptproblem kaum. Beansprucht dagegen die Ausführung unabhängiger Tests den größten Teil der Laufzeit, können zusätzliche Runner helfen.
Ein Shard enthält dabei einen Teil der Testsuite. Bei vier Shards starten vier Jobs mit unterschiedlichen Teilmengen der Tests. Stehen genügend Runner bereit, arbeiten sie gleichzeitig. Die Dauer des Gesamtlaufs bestimmt dann der zuletzt fertige Job. Vergleichbar ist das Ergebnis aber nur, wenn alle Shards zusammen dieselben Tests abdecken wie der bisherige, gemeinsame Lauf. Ein kurzer grüner Job allein belegt daher noch keine erfolgreiche Beschleunigung.
Drei Ebenen der Parallelität
Testframeworks wie xUnit können Tests innerhalb einer Assembly parallel ausführen. Eine Assembly ist hier das kompilierte Testprojekt. Bei xUnit bildet standardmäßig jede Testklasse eine eigene Testcollection, und mehrere Klassen lassen sich auch ausdrücklich zusammenfassen. Im Modus collections laufen verschiedene Collections parallel, die Tests innerhalb einer Collection nacheinander. Seit xUnit v3 4.0 erlaubt der Modus all zusätzliche Parallelität innerhalb einer Collection. Die konkrete Konfiguration bestimmt also, welche Tests gleichzeitig laufen dürfen.
Darüber liegt die Parallelisierung mehrerer Testmodule oder Ziel-Frameworks durch den Testrunner. Sharding ergänzt eine dritte Ebene: Mehrere CI-Jobs erhalten unterschiedliche Teilmengen derselben Suite. ShardedTest berechnet diese Teilmengen, reserviert aber keine Maschinen. GitHub Actions beziehungsweise GitLab CI starten die Jobs auf verfügbaren CI-Runnern. Testrunner und CI-Runner übernehmen somit unterschiedliche Aufgaben.
Diese Ebenen lassen sich kombinieren. Greifen die Tests auf eine gemeinsame Datenbank zu, erhöhen vier Shards mit jeweils mehreren parallel laufenden Tests allerdings auch die gleichzeitigen Zugriffe. Zudem synchronisiert eine Collection keine getrennten CI-Prozesse: Tests, die nur aufgrund gemeinsamer Framework-Sperren zuverlässig funktionieren, brauchen vor dem Sharding eine andere Ressourcenaufteilung.
Was Meziantou.ShardedTest auswählt
Die Beispiele in diesem Teil und den folgenden Teilen verwenden Meziantou.ShardedTest 1.1.0, veröffentlicht am 18. August 2026. Diese zum Recherchezeitpunkt aktuelle Version benötigt .NET 10. Die feste Versionsangabe sorgt dafür, dass lokale Aufrufe und CI-Jobs dasselbe Werkzeug verwenden.
Jeder Shard ruft zunächst dotnet test --list-tests für die gesamte Suite auf. Danach sortiert das Tool die Anzeigenamen mit StringComparer.Ordinal, also unabhängig von der eingestellten Sprache. Aus der Listenposition, beginnend bei null, berechnet es dann die Shardnummer: Position modulo Shardanzahl, also der Rest der Division, plus eins. Bei drei Shards gehören daher die Positionen 0 und 3 zu Shard 1. Für sechs sortierte Tests sieht das so aus:
| Shard | Listenpositionen | Tests |
|---|---|---|
| 1 | 0 und 3 | A und D |
| 2 | 1 und 4 | B und E |
| 3 | 2 und 5 | C und F |
Das ist eine Verteilung im Rundlauf, kein Hashing des Testnamens und keine Gewichtung nach Laufzeiten. Gleich viele Tests je Shard können deshalb sehr unterschiedliche Arbeit bedeuten. Ein zusätzlicher Test am Anfang der Sortierung verschiebt außerdem die nachfolgenden Zuordnungen. Deterministisch bedeutet hier: Gleiche entdeckte Testliste und gleiche Shardanzahl ergeben dieselbe Aufteilung. Eine dauerhaft feste Heimat jedes Tests verspricht das Verfahren nicht.
Weil jeder Job seine Zuordnung selbst berechnet, braucht es keinen zentralen Verteiler. Alle Jobs müssen dafür mit demselben Testkatalog arbeiten. Tests dürfen auch nicht voraussetzen, dass vorher ein anderer Test die notwendigen Daten erzeugt hat: Dieser könnte auf einem anderen Runner liegen.
Zur Ausführung erzeugt das Tool Filter auf DisplayName, den angezeigten Testnamen. Bei einer xUnit-Theory kann dieser auch die Parameter eines Datensatzes enthalten. So lassen sich Datensätze derselben Methode einzeln auswählen, sofern die Discovery sie einzeln meldet. Dynamische Datenquellen und doppelte Anzeigenamen brauchen einen Vergleich mit dem vollständigen Testlauf: Jeder Job muss dieselbe Liste sehen und jeder Filter die beabsichtigten Fälle treffen.
Eine gemeinsame Projektgrundlage
Die Serie verwendet .NET 10 und xUnit auf Microsoft.Testing.Platform, kurz MTP. MTP übernimmt den Start der Testanwendung und die Kommunikation mit dotnet test. xUnit definiert und prüft die Testfälle. ShardedTest kann auch VSTest ansteuern. Dessen Kommandozeilenoptionen unterscheiden sich jedoch von denen des nativen MTP-Modus. Dieser erfordert .NET SDK 10 und MTP ab Version 1.7. Allein die Installation des SDK stellt bestehende Testprojekte nicht um.
Alle folgenden Befehle starten im Repository-Hauptverzeichnis. Dort wählt global.json den MTP-Modus. Bereits vorhandene SDK-Einstellungen bleiben erhalten. Für reproduzierbare Messungen sollte die Datei zusätzlich eine konkrete SDK-Version festlegen:
{
"test": {
"runner": "Microsoft.Testing.Platform"
}
}
Die Datei tests/ShardDemo.Tests/ShardDemo.Tests.csproj hat folgenden Inhalt:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3.mtp-v2"
Version="4.0.0" />
<PackageReference
Include="Microsoft.Testing.Extensions.TrxReport"
Version="2.3.3" />
</ItemGroup>
</Project>
Die fixierten Pakete entsprechen der Kombination in den Funktionstests von ShardedTest 1.1.0. xunit.v3.mtp-v2 bringt xUnit mit MTP-Anbindung mit. Der OutputType Exe macht das Testprojekt zur ausführbaren Testanwendung. Das TRX-Paket ergänzt Ergebnisberichte für die CI. TRX ist Microsofts XML-Format für Testergebnisse. Im selben Ordner liegt die Datei ArithmeticTests.cs mit folgenden acht festen Datensätzen:
using Xunit;
namespace ShardDemo.Tests;
public class ArithmeticTests
{
[Theory]
[InlineData(1, 2)]
[InlineData(2, 4)]
[InlineData(3, 6)]
[InlineData(4, 8)]
[InlineData(5, 10)]
[InlineData(6, 12)]
[InlineData(7, 14)]
[InlineData(8, 16)]
public void Doubles(int input, int expected)
=> Assert.Equal(expected, input * 2);
}
Bild 1 zeigt die erfolgreiche Testausführung in Visual Studio 2026 Community.
Erfolgreiche Testausführung in Visual Studio (Bild 1)
AutorTheory kennzeichnet einen parametrisierten Test. Jedes InlineData-Paar liefert input und expected. Assert.Equal prüft das Rechenergebnis (siehe Bild 2). So entstehen aus nur einer Testmethode acht einzeln entdeckbare Testfälle.
Eine Testmethode, acht Testfälle: xUnit führt jeden InlineData-Datensatz als eigenen Fall aus (Bild 2)
AutorDie Shell-Listings verwenden Bash, unter PowerShell lassen sich die Aufrufe jeweils in eine Zeile schreiben. Das lokale Toolmanifest .config/dotnet-tools.json hält die Werkzeugversion für Entwicklung und CI fest. Die folgenden Aufrufe zeigen Beispiele für die Nutzung der Demo:
dotnet new tool-manifest dotnet tool install Meziantou.ShardedTest --version 1.1.0 dotnet build tests/ShardDemo.Tests -c Release dotnet tool run sharded-test --shard-index 1 --total-shards 4 \ --project tests/ShardDemo.Tests/ShardDemo.Tests.csproj \ --configuration Release --no-build --list-tests
Existiert bereits ein Toolmanifest, entfällt dessen Neuanlage. Der Build erzeugt zunächst das Release-Kompilat. --list-tests zeigt anschließend nur die Auswahl von Shard 1, hier zwei der acht Fälle. Ohne die Option führt das Tool sie aus. Für die übrigen Shards wechselt --shard-index auf 2, 3 und 4; --total-shards bleibt 4. --no-build verhindert erneutes Bauen und impliziert --no-restore. Bei MTP kennzeichnet --project ausdrücklich den Projektpfad. Bild 3 zeigt die Ausgabe auf der Konsole mit der Testauswahl aller vier Shards.
Jeder Shard erhält zwei der acht Testfälle. Die Auswahl folgt der ordinal sortierten Liste der Anzeigenamen (Bild 3)
AutorZwischenfazit: Von der lokalen Auswahl zur CI
Die kleine Suite demonstriert die Zuordnung, bewirkt jedoch keinen realistischen Performancegewinn: Ihr Prozessstart dürfte mehr kosten als die Assertions, und lokal nacheinander gestartete Shards beschleunigen ohnehin nichts. Der nächste Teil überträgt denselben Aufruf in GitHub Actions, wo mehrere Jobs gleichzeitig arbeiten können. Sinnvoll messen lässt sich der Nutzen des Shardings aber erst mit einer ausreichend großen Suite.