Wenn ein iOS-Projekt mehrere Tausend Unit- und UI-Tests umfasst, übernimmt ein einzelner Aufruf von xcodebuild test normalerweise alles zugleich: Kompilierung, Start des Simulators, Installation der App und Ausführung sämtlicher Testfälle. Tritt der Fehler erst in den letzten Minuten auf, beginnt auch der Wiederholungslauf erneut mit der Kompilierung. Auf einem Cloud Mac lässt sich dieser Ablauf besser in zwei Phasen aufteilen: Zuerst wird einmalig ein testfähiges Build-Produkt erzeugt, das anschließend von mehreren klar abgegrenzten Test-Shards wiederverwendet wird.
Build und Testausführung voneinander trennen
Mit build-for-testing erstellt Xcode die App, den Test-Host und die Test-Bundles sowie eine .xctestrun-Datei, die die Ausführungsumgebung beschreibt. Anschließend verhindert test-without-building, dass jeder Shard Abhängigkeiten erneut auflösen und den Quellcode neu kompilieren muss.
Zunächst werden Workspace, Scheme, Zielgerät und DerivedData-Pfad festgelegt:
set -euo pipefail
DERIVED_DATA="$PWD/.ci/DerivedData"
RESULTS="$PWD/.ci/Results"
DESTINATION="platform=iOS Simulator,name=iPhone 16,OS=latest"
rm -rf "$DERIVED_DATA" "$RESULTS"
mkdir -p "$RESULTS"
xcodebuild build-for-testing \
-workspace App.xcworkspace \
-scheme App \
-configuration Debug \
-destination "$DESTINATION" \
-derivedDataPath "$DERIVED_DATA" \
CODE_SIGNING_ALLOWED=NO
find "$DERIVED_DATA/Build/Products" -name "*.xctestrun" -print
CODE_SIGNING_ALLOWED=NO eignet sich nur für Simulatortests, die keine Signierung für ein physisches Gerät benötigen. Enthält das Test-Target Komponenten, die zwingend signiert werden müssen, ist dieser Parameter zu entfernen, damit die Signing-Konfiguration des Projekts greift. Nach einem erfolgreichen Build darf DerivedData nicht verschoben werden, da die .xctestrun-Datei absolute Pfade dorthin enthalten kann.
Sharding löst Probleme bei der Testverteilung, nicht jedoch gemeinsam genutzten Zustand zwischen Tests. Ist ein Testfall darauf angewiesen, dass zuvor ein anderer ausgeführt wurde, kann er auf einem einzelnen Rechner bei serieller Ausführung stabil wirken. Nach der Aufteilung wird dieser tatsächliche Fehler jedoch sichtbar.
Stabile Shards über Testlisten definieren
Tests sollten nicht manuell in Gruppen von „ungefähr hundert Methoden“ aufgeteilt werden. Methodennamen ändern sich häufig, wodurch ein hoher Pflegeaufwand entsteht. Stabiler ist eine Aufteilung nach Test-Target, Testklasse oder fachlichem Bereich, wobei die Listen im Repository versioniert werden.
| Shard | Empfohlener Bereich | Geeignete Tests |
|---|---|---|
| unit-core | Reine Logikschicht | Datentransformation, Validierung, Zustandsautomaten |
| unit-storage | Persistenzschicht | Datenbanken, Caches, Migrationen |
| ui-account | Kontoabläufe | Anmeldung, Einstellungen, Berechtigungsseiten |
| ui-checkout | Transaktionsabläufe | Produktauswahl, Bestätigung und Fehlerpfade |
Die Listen können als einfache Textdateien geführt werden:
AppTests/ParserTests
AppTests/SessionReducerTests
AppTests/ValidationTests
Bei der Ausführung wird jede Zeile in einen eigenen Parameter -only-testing: umgewandelt. Testbezeichner folgen üblicherweise dem Format Target/Class oder Target/Class/testMethod. Zuerst sollte das Scheme mit xcodebuild -list geprüft werden. Anschließend empfiehlt sich ein kleiner Testlauf zur Validierung der Bezeichner, damit ein Tippfehler nicht dazu führt, dass ein Shard tatsächlich keinen einzigen Test ausführt.
Einen einzelnen Shard ausführen
XCTESTRUN="$(find "$DERIVED_DATA/Build/Products" -name '*.xctestrun' -print -quit)"
xcodebuild test-without-building \
-xctestrun "$XCTESTRUN" \
-destination "$DESTINATION" \
-parallel-testing-enabled NO \
-only-testing:AppTests/ParserTests \
-only-testing:AppTests/SessionReducerTests \
-resultBundlePath "$RESULTS/unit-core.xcresult"
Jeder Shard muss einen eigenen resultBundlePath verwenden. Wird derselbe Pfad mehrfach genutzt, werden Nachweise überschrieben; außerdem können parallele Prozesse um dasselbe Verzeichnis konkurrieren.
Jedem parallelen Job einen eigenen Simulator zuweisen
Greifen zwei xcodebuild-Prozesse gleichzeitig auf denselben Simulator zu, können sich Installation, Startvorgang, Berechtigungsstatus und Zwischenablagedaten gegenseitig beeinflussen. Daher sollte jeder parallele Slot ein eigenes Gerät erhalten, das über seine UDID ausgewählt wird.
Zunächst werden die verfügbaren Runtimes angezeigt:
xcrun simctl list runtimes
xcrun simctl list devicetypes
Nach Ermittlung der Runtime-ID der aktuellen Umgebung wird ein eigenes Gerät erstellt:
xcrun simctl create \
ci-shard-1 \
com.apple.CoreSimulator.SimDeviceType.iPhone-16 \
"$RUNTIME_ID"
Die zurückgegebene UDID wird in einer kontrollierten Laufzeitkonfiguration gespeichert. Vor jedem Durchlauf wird das Gerät heruntergefahren und sein Zustand vollständig gelöscht:
xcrun simctl shutdown "$SIMULATOR_UDID" 2>/dev/null || true
xcrun simctl erase "$SIMULATOR_UDID"
xcrun simctl boot "$SIMULATOR_UDID"
xcrun simctl bootstatus "$SIMULATOR_UDID" -b
Danach wird die Destination auf platform=iOS Simulator,id=$SIMULATOR_UDID gesetzt. Benötigt ein Job eine voreingestellte Sprache, bestimmte Berechtigungen oder Testdaten, sollten diese nach erase einheitlich eingespielt werden. Auf Zustand aus einem vorherigen Lauf darf er sich nicht verlassen.
Parallelität anhand der Ressourcenauslastung steuern
Werden zwei Shards explizit parallel gestartet und aktiviert jeder Shard zusätzlich die integrierte Parallelisierung von Xcode, entsteht verschachtelte Parallelität. Die Zahl der Simulatoren, Testprozesse und unterstützenden Build-Prozesse steigt gleichzeitig, sodass der gesamte Lauf am Ende langsamer als eine serielle Ausführung sein kann. In der ersten Ausbaustufe sollte daher -parallel-testing-enabled NO gesetzt und jeweils nur ein weiterer Shard-Slot ergänzt werden.
Bei der Beobachtung sollten mindestens drei Signalgruppen erfasst werden:
- ob
memory_pressuredauerhaft eine hohe Speicherauslastung meldet; - ob Komprimierung und Paging in
vm_statschnell zunehmen; - ob Anzahl der Testfälle, Laufzeit und Fehlerposition jedes Shards stabil bleiben.
Shards sollten nicht allein auf eine identische Anzahl von Tests optimiert werden. Eine UI-Testklasse mit zahlreichen App-Neustarts kann langsamer sein als mehrere Hundert Tests reiner Funktionen. Die Listen können anhand der Laufzeiten mehrerer aktueller Pipeline-Durchläufe angepasst werden. Eine zufällige Verteilung zur Laufzeit sollte jedoch vermieden werden, da sie die Reproduktion von Fehlern und den Vergleich zeitlicher Trends erschwert.
Fehler gezielt wiederholen und Beweise sichern
Für jeden Shard sollten Exitcode, Konsolenprotokoll und eine separate .xcresult-Datei gespeichert werden. Die Zusammenführungsphase der Pipeline darf fehlschlagen, sollte jedoch nicht beim ersten fehlgeschlagenen Shard die Nachweise der übrigen Shards löschen. Beim erneuten Ausführen einer fehlgeschlagenen Gruppe wird weiterhin die ursprüngliche .xctestrun-Datei verwendet, aber ein anderer Ergebnispfad angegeben:
xcodebuild test-without-building \
-xctestrun "$XCTESTRUN" \
-destination "platform=iOS Simulator,id=$SIMULATOR_UDID" \
-parallel-testing-enabled NO \
-only-testing:AppUITests/CheckoutTests \
-resultBundlePath "$RESULTS/ui-checkout-retry-1.xcresult"
Ändern sich Quellcode, Compileroptionen, Xcode-Version oder Simulator-Runtime, muss build-for-testing erneut ausgeführt werden. Alte Build-Produkte dürfen nicht als Nachweis für einen neuen Commit dienen. Erst wenn Testlisten, Build-Protokolle, Shard-Ergebnisse und Umgebungsversionen gemeinsam aufbewahrt werden, lässt sich feststellen, ob ein Fehler aus dem Code, dem Testzustand oder der Ausführungsumgebung stammt.
Entscheidend an dieser Struktur ist nicht, den Testbefehl mehrfach zu kopieren, sondern drei klare Zuständigkeiten festzulegen: Das Build-Produkt wird nur einmal erzeugt, jeder Simulator gehört exklusiv zu einem einzelnen Shard, und Ergebnis-Bundles überschreiben sich niemals gegenseitig. Sind diese drei Bedingungen erfüllt, lassen sich Shards skalieren und fehlgeschlagene Gruppen gezielt erneut ausführen, ohne die Reproduzierbarkeit zu beeinträchtigen.
Häufig gestellte Fragen
Warum nicht nur die parallele Testausführung von Xcode verwenden?
Die eingebaute Parallelisierung verteilt Tests innerhalb eines Aufrufs. Explizite Shards bieten feste Zuständigkeiten, getrennte Ergebnisse und gezielte Wiederholungen.
Muss nach einem fehlgeschlagenen Shard neu gebaut werden?
Nein, solange Build-Produkte, xctestrun-Datei und Werkzeugversionen unverändert verfügbar sind. Dann kann nur der betroffene Shard erneut ausgeführt werden.
Sie benötigen einen physischen Mac mini zur exklusiven Nutzung in einem Einzelauftrag?
Prüfen Sie die Konfigurationen M4 und M4 Pro, vier Mietlaufzeiten und fünf verfügbare Knoten und wählen Sie anschließend das passende Gerät für Ihren Workflow.