Bei nächtlichen CI-Jobs tritt gelegentlich ein XCTest-Fehler auf, der sich lokal nicht reproduzieren lässt: Die Eingaben stammen aus einer Zufallsfabrik, die Tests laufen in zufälliger Reihenfolge, und nach einem Fehler startet die Pipeline automatisch einen neuen Versuch. Der zweite Durchlauf ist erfolgreich, doch welche Daten beim ersten Fehler verwendet wurden und welche Tests zuvor ausgeführt worden waren, lässt sich nicht mehr feststellen. Solche Probleme löst man nicht mit zusätzlichen Wiederholungen, sondern indem man jeden Testlauf in ein vollständig reproduzierbares Experiment verwandelt.
Zuerst einen Replay-Vertrag definieren
Zufallsbasierte Tests werden von mindestens drei Variablengruppen beeinflusst: den zufälligen Werten der Anwendungsdaten, der Ausführungsreihenfolge der Testfälle sowie dem Zustand, den Simulator und Prozess hinterlassen. Nur SEED=42 zu protokollieren reicht nicht aus, denn nach Änderungen am Commit, an der Testliste oder an den Regionaleinstellungen kann derselbe Seed einen anderen Ausführungspfad erzeugen.
Zu Beginn jedes Jobs sollte ein Laufmanifest erstellt werden, das von allen nachfolgenden Schritten ausschließlich gelesen wird. Mindestens die folgenden Felder sollten darin enthalten sein:
| Feld | Beispiel | Zweck |
|---|---|---|
| commit | Git-Commit-Hash | Getesteten Code fixieren |
| seed | Vorzeichenlose Ganzzahl | Zufallsdaten reproduzieren |
| tests | Geordnete Liste von Testkennungen | Ausführungsreihenfolge fixieren |
| destination | Simulator-UDID | Ausführungsziel fixieren |
| runtime | System- und Xcode-Version | Unterschiede in der Toolchain erkennen |
| locale | zh_CN oder en_US |
Formatierungsunterschiede vermeiden |
| result bundle | Pfad zur .xcresult-Datei |
Protokolle und Anhänge sichern |
„Derselbe Test war beim nächsten Mal erfolgreich“ ist keine Reproduktion. Erst wenn Eingaben, Reihenfolge und Umgebung identisch sind, lässt sich feststellen, ob der Fehler weiterhin besteht.
Das Laufmanifest muss zusammen mit den Testergebnissen archiviert werden. Es darf nicht nur im leicht kürzbaren Konsolenprotokoll erscheinen.
Zufallsdaten mit einem stabilen Seed erzeugen
Die systemeigene Zufallsquelle von Swift eignet sich für Produktionscode, kann aber nicht mit einem vorgegebenen Seed reproduziert werden. Stattdessen lässt sich der Zufallszahlengenerator als Abhängigkeit an die Datenfabrik übergeben. Tests verwenden dann eine seedbare Implementierung, während der Produktionscode weiterhin auf die Systemimplementierung zurückgreift.
struct SplitMix64: RandomNumberGenerator {
private var state: UInt64
init(seed: UInt64) {
state = seed
}
mutating func next() -> UInt64 {
state &+= 0x9E3779B97F4A7C15
var value = state
value = (value ^ (value >> 30)) &* 0xBF58476D1CE4E5B9
value = (value ^ (value >> 27)) &* 0x94D049BB133111EB
return value ^ (value >> 31)
}
}
func makeQuantity<R: RandomNumberGenerator>(using rng: inout R) -> Int {
Int.random(in: 1...20, using: &rng)
}
Der Testeinstiegspunkt liest den Seed aus einer Umgebungsvariablen und bricht bei einem fehlenden Wert direkt ab, anstatt unbemerkt auf die systemeigene Zufallsquelle zurückzufallen:
let rawSeed = ProcessInfo.processInfo.environment["TEST_SEED"]
let seed = rawSeed.flatMap(UInt64.init)
XCTAssertNotNil(seed, "TEST_SEED must be provided")
Alle Zufallsfabriken innerhalb desselben Jobs sollten eine klar definierte Ableitungsregel verwenden. Beispielsweise kann aus dem Haupt-Seed und einer stabilen Testkennung ein Sub-Seed berechnet werden. So verändert ein zusätzlicher Zufallswert in einem einzelnen Test nicht die Datensequenz der gesamten Testsuite.
Testliste und Ausführungsreihenfolge fixieren
Die Testreihenfolge darf nicht vom Ergebnis einer Dateisystemdurchsuchung abhängen. Stattdessen sollte eine Textdatei gepflegt werden, die pro Zeile eine vollständige Kennung wie AppTests/LoginTests/testExpiredSession enthält. Anschließend werden die Tests anhand eines Hashwerts aus Seed und Kennung stabil sortiert:
import hashlib
import os
seed = os.environ["TEST_SEED"]
with open("ci/tests.txt", encoding="utf-8") as source:
tests = [line.strip() for line in source if line.strip()]
def key(name):
value = f"{seed}:{name}".encode()
return hashlib.sha256(value).hexdigest()
for test in sorted(tests, key=key):
print(test)
Damit lassen sich unterschiedliche Reihenfolgen abdecken und zugleich auf jedem beliebigen Knoten identisch rekonstruieren. Vor der Ausführung wird das sortierte Ergebnis als ordered-tests.txt gespeichert. Daraus werden anschließend nacheinander die Parameter für xcodebuild zusammengesetzt:
set -euo pipefail
mkdir -p artifacts
python3 ci/order_tests.py > artifacts/ordered-tests.txt
args=()
while IFS= read -r test_id; do
args+=("-only-testing:${test_id}")
done < artifacts/ordered-tests.txt
xcodebuild test \
-workspace App.xcworkspace \
-scheme App \
-destination "id=${SIMULATOR_UDID}" \
-resultBundlePath "artifacts/test-${TEST_SEED}.xcresult" \
"${args[@]}"
Parallel dazu darf keine weitere, nicht protokollierte Zufallssortierung aktiviert sein. Andernfalls stimmen die Reihenfolge im Manifest und die tatsächliche Ausführungsreihenfolge erneut nicht überein.
Simulatorzustand isolieren, ohne Beweise zu löschen
Für die Reproduktion sollte ein dedizierter Simulator verwendet werden, damit manuelle Debugging-Sitzungen und CI nicht dasselbe Gerät nutzen. Vor Testbeginn kann das angegebene Gerät heruntergefahren und gelöscht werden. Dabei muss jedoch sichergestellt sein, dass die UDID aus dem aktuellen Job stammt; gemeinsam verwendete Geräte dürfen nicht pauschal bereinigt werden.
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
Region, Sprache, Zeitzone und Startparameter sollten ebenfalls explizit am Testeinstiegspunkt festgelegt werden. Wenn Netzwerkantworten, die aktuelle Uhrzeit oder UUIDs Assertions beeinflussen, müssen feste Implementierungen über Protokolle injiziert werden. Man darf sich nicht darauf verlassen, dass sich der Simulator zufällig im gleichen Zustand befindet.
Falls der Fehler vermutlich durch einen vorherigen Test verursacht wird, sollten nicht sofort alle Testfälle in jeweils neue Simulatoren ausgelagert werden. Zunächst wird die ursprüngliche Reihenfolge reproduziert und der Fehler bestätigt. Danach lässt sich die Liste der vorausgehenden Tests per binärer Suche verkürzen, bis die kleinste Kette zustandsverändernder Tests gefunden ist. So kann zwischen einem durch Zufallsdaten ausgelösten Fehler und einem Zustandsleck aus vorherigen Tests unterschieden werden.
Beim ersten Fehler den vollständigen Zustand sichern
Bei einem fehlgeschlagenen Job müssen mindestens xcresult, die geordnete Testliste, das Laufmanifest, die Standardausgabe und der Code-Commit erhalten bleiben. Zusätzlich sollten xcodebuild -version, sw_vers und die Simulator-Runtime protokolliert werden. Bei vertraulichen Umgebungsvariablen werden nur der Variablenname und das Vorhandensein des Werts erfasst, niemals der tatsächliche Inhalt.
Automatische Wiederholungen können zur Klassifizierung dienen, dürfen aber erst nach der Archivierung des ersten Fehlerzustands stattfinden. Es empfiehlt sich, die Ergebnisse in drei Kategorien einzuteilen:
Stabiler Fehler bei identischem Vertrag
Wenn der Fehler bei identischem Commit, Seed, identischer Reihenfolge und identischem Simulatorzustand dauerhaft auftritt, sollten zuerst deterministische Anwendungslogik und Assertions untersucht werden.
Fehler nach Änderung der Reihenfolge
Wenn die Daten unverändert bleiben, sich das Ergebnis aber durch eine andere Reihenfolge der vorausgehenden Tests ändert, sind insbesondere Singletons, Notification-Listener, Datenbanken, Caches und nicht zurückgesetzte globale Konfigurationen zu prüfen.
Weiterhin abweichendes Verhalten bei identischem Vertrag
In diesem Fall ist weiter zu prüfen, ob asynchrone Aufgaben tatsächlich beendet wurden, ob die Systemzeit ausgelesen wird, ob Netzwerkantworten nicht fixiert sind und ob Tests eine nicht injizierte Zufallsquelle verwenden.
Das eigentliche Ziel besteht nicht darin, einen roten Test durch Wiederholungen verschwinden zu lassen. Stattdessen muss jeder Fehler mit einem ausführbaren Replay-Befehl verknüpft sein. Sobald das erreicht ist, wird aus einem sporadischen Ereignis eine Eingabemenge, die sich verkleinern, vergleichen und korrigieren lässt.
Häufig gestellte Fragen
Reicht der Zufalls-Seed zur Reproduktion eines XCTest-Fehlers aus?
Meistens nicht. Zusätzlich müssen Commit, Testliste und Reihenfolge, Simulator-Runtime, Gebietsschema und externe Eingaben festgelegt werden.
Sollte die CI einen fehlgeschlagenen Test sofort wiederholen?
Zuerst werden das ursprüngliche xcresult, der Seed und die genaue Reihenfolge gesichert. Danach folgt ein separater Lauf mit demselben Replay-Vertrag; ein erfolgreicher Retry ist keine Fehlerbehebung.
Warum sollte Swift einen Zufallsgenerator per Injection erhalten?
Tests können einen Generator mit festem Seed einsetzen und dieselbe Wertefolge wiederholen, während der Produktivcode weiterhin den Systemgenerator nutzt.
Einen exklusiven physischen Mac für den nächsten Build vorbereiten
Modell, Rechenzentrum und Mietdauer passend zur Aufgabe auswählen. Die Knoten laufen ganzjährig stabil; maßgeblich ist der in der Konsole angezeigte Status in Echtzeit.