Dieselben Swift-Tests laufen einzeln fehlerfrei, schlagen aber in parallelen Jobs auf einem Cloud-Mac gelegentlich fehl. Meist liegt das nicht an einer instabilen Maschine, sondern daran, dass sich die Tests Verzeichnisse, Einstellungen, Datenbanken, Ports oder Zufallszustände teilen. Zusätzliche Wiederholungsversuche kaschieren diese Verunreinigung lediglich. Zuverlässiger ist es, jedem Test eine eindeutig identifizierbare, bereinigbare und reproduzierbare Ausführungsumgebung zuzuweisen.
Zuerst prüfen, ob der Fehler durch gemeinsam genutzten Zustand entsteht
Behalten Sie die Parallelisierung zunächst bei, statt sofort auf eine serielle Ausführung umzustellen. Führen Sie dasselbe Testziel einmal mit einem einzelnen und einmal mit mehreren Workern aus. Erzeugen Sie für beide Läufe jeweils ein separates Ergebnis-Bundle:
set -o pipefail
xcodebuild test \
-scheme AppTests \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-parallel-testing-enabled NO \
-resultBundlePath Artifacts/serial.xcresult
xcodebuild test \
-scheme AppTests \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-parallel-testing-enabled YES \
-maximum-parallel-testing-workers 4 \
-resultBundlePath Artifacts/parallel.xcresult
Wenn der Lauf mit einem Worker stabil ist, die parallele Ausführung aber fehlschlägt, sollten Sie zuerst die folgenden Ressourcen untersuchen, anstatt die Assertions infrage zu stellen:
| Gemeinsam genutzte Ressource | Typische Symptome | Isolationsmethode |
|---|---|---|
| Temporäres Verzeichnis | Dateien werden überschrieben oder fehlen bei der Bereinigung | Für jeden Test ein eindeutiges Unterverzeichnis erstellen |
| UserDefaults | Werte werden gelesen, die ein anderer Test geschrieben hat | Einen separaten suiteName verwenden |
| SQLite-Datei | Sperrkonflikte oder schwankende Datensatzanzahl | Für jeden Test eine eigene Datenbank verwenden |
| Fester Port | Address already in use | Port 0 binden |
| Globaler Zufallszustand | Ergebnisse ändern sich bei anderer Reihenfolge | Zufalls-Seed explizit speichern |
Ein erfolgreicher serieller Lauf beweist nur, dass nicht gleichzeitig auf den gemeinsam genutzten Zustand zugegriffen wurde. Er beweist nicht, dass die Tests korrekt isoliert sind.
Jedem Test eine eigene Sandbox zuweisen
Lassen Sie nicht alle Tests nach /tmp/app-tests schreiben. Erzeugen Sie beim Start jedes Tests eine eindeutige Kennung, speichern Sie Dateien, Datenbanken und exportierte Ergebnisse im zugehörigen Verzeichnis und bereinigen Sie es nach Abschluss.
import Foundation
import Testing
struct TestSandbox {
let id: String
let root: URL
init(name: String) throws {
id = "\(name)-\(UUID().uuidString)"
root = FileManager.default.temporaryDirectory
.appending(path: "ZoneMiniTests")
.appending(path: id)
try FileManager.default.createDirectory(
at: root,
withIntermediateDirectories: true
)
}
func preferences() throws -> UserDefaults {
guard let defaults = UserDefaults(suiteName: "tests.\(id)") else {
throw CocoaError(.fileWriteUnknown)
}
return defaults
}
func remove() throws {
try FileManager.default.removeItem(at: root)
UserDefaults.standard.removePersistentDomain(
forName: "tests.\(id)"
)
}
}
Übergeben Sie root per Dependency Injection an den zu testenden Code, damit der Produktionscode nicht selbst auf einen globalen temporären Pfad zugreift. Die Bereinigung gehört in einen defer-Block, damit sie auch nach einer fehlgeschlagenen Assertion ausgeführt wird. Soll der Fehlerzustand erhalten bleiben, können Sie das Löschen abhängig von einer Umgebungsvariable überspringen und den Sandbox-Pfad als Testanhang speichern.
Den Testnamen nicht als einzigen Schlüssel verwenden
Parametrisierte Tests können mehrere Eingabesätze mit demselben Funktionsnamen parallel ausführen. Der Funktionsname allein verhindert daher keine Kollisionen. Kombinieren Sie stattdessen Testname, Parameterzusammenfassung und UUID. Enthalten die Parameter Token oder Benutzerdaten, dürfen diese nicht direkt in den Verzeichnisnamen übernommen werden. Erzeugen Sie zuvor einen nicht umkehrbaren Hash.
Konfiguration, Datenbank und Listening-Port isolieren
Die Standard-Domain von UserDefaults ist ein prozessweit gemeinsam genutzter Zustand. Tests sollten eine dedizierte Instanz injizieren und die zugehörige persistente Domain nach Abschluss entfernen. Für Datenbanken gilt dasselbe Prinzip: Jeder Test erstellt seine eigene Datei. Migrationstests und normale Lese- oder Schreibtests dürfen nicht dieselbe Kopie verwenden.
Bei Netzwerktests führen feste Ports besonders häufig zu Scheinfehlern. Der Testserver sollte Port 0 binden, damit das System einen freien Port auswählt. Anschließend wird der tatsächlich zugewiesene Port an den Client übergeben. Vermeiden Sie es, zuerst einen freien Port zu suchen, die Prüfverbindung zu schließen und ihn danach erneut zu binden. Zwischen Prüfung und Bindung besteht ein Race Window.
Tests, die tatsächlich von einem globalen Singleton abhängen und kurzfristig nicht umgebaut werden können, lassen sich gezielt serialisieren:
import Testing
@Suite(.serialized)
struct LegacyDatabaseTests {
@Test
func migratesExistingStore() async throws {
// Testimplementierung
}
}
.serialized sollte ausschließlich den Legacy-Bereich umfassen. Wird das gesamte Testziel serialisiert, verschwindet zwar die parallele Verunreinigung, gleichzeitig gehen aber auch Ausführungsgeschwindigkeit und wichtige Hinweise auf die Ursache verloren.
Zufalls-Seeds festhalten, statt auf Wiederholungen zu hoffen
Wenn Tests zufällige Reihenfolgen, Retry-Backoff oder generierte Daten verwenden, sollten sie den Seed aus einer Umgebungsvariable lesen. Erzeugen und protokollieren Sie ihn einmal zu Beginn des Jobs. Alle Testprozesse leiten daraus ihre eigenen Sub-Seeds ab.
let environment = ProcessInfo.processInfo.environment
let seed = UInt64(environment["TEST_SEED"] ?? "") ?? 20260806
Ein Sub-Seed kann stabil aus dem Basis-Seed und dem eindeutigen Testschlüssel berechnet werden. Ein Fehlerbericht sollte mindestens Basis-Seed, Worker-Anzahl, Testziel und Ausführungsbefehl enthalten. Nur so lässt sich die Kombination aus einer bestimmten Eingabe und einem bestimmten Parallelisierungsgrad reproduzieren, statt den Test zehnmal blind erneut auszuführen.
Auf kontinuierlich betriebenen Nodes von ZoneMini empfiehlt es sich, Ergebnis-Bundles in einem Verzeichnis zu speichern, das der jeweiligen Job-ID entspricht. So überschreibt ein späterer Job nicht die Ergebnisse des vorherigen:
RUN_ID="${CI_RUN_ID:-local-$(date +%s)}"
mkdir -p "Artifacts/$RUN_ID"
TEST_SEED=20260806 xcodebuild test \
-scheme AppTests \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-parallel-testing-enabled YES \
-maximum-parallel-testing-workers 4 \
-resultBundlePath "Artifacts/$RUN_ID/tests.xcresult"
Die Korrektur durch wiederholte Lasttests verifizieren
Führen Sie den Test nach der Korrektur nicht nur einmal aus. Wiederholen Sie ihn zunächst mit demselben Seed und derselben Parallelität und wechseln Sie anschließend den Seed, um unterschiedliche Eingaben abzudecken. Das Ergebnis-Bundle jedes Durchlaufs muss einen eigenen Pfad verwenden. Andernfalls beendet sich xcodebuild, weil das Ziel bereits existiert.
Für die Abnahme eignen sich vier eindeutige Kriterien:
- Die Ausführung mit einem Worker und die Ausführung mit vier Workern liefern dieselben Assertion-Ergebnisse.
- Jeder Test schreibt ausschließlich in sein eigenes Verzeichnis, seine eigene Einstellungs-Domain und seine eigene Datenbank.
- Listening-Dienste verwenden keine hartcodierten Ports.
- Fehlerprotokolle verweisen eindeutig auf Seed, Sandbox und Ergebnis-Bundle.
Prüfen Sie abschließend die Bereinigungsstrategie. Bei erfolgreichen Jobs können temporäre Sandboxes gelöscht werden. Bei fehlgeschlagenen Jobs sollten die erforderlichen Protokolle und Ergebnis-Bundles erhalten bleiben, jedoch keine Zugangsdaten, Repository-Token oder nicht anonymisierten Request-Inhalte langfristig gespeichert werden. Stabile parallele Tests entstehen nicht durch mehr Wiederholungsversuche, sondern dadurch, dass jeder Test ausschließlich seinen eigenen Zustand besitzt und jeder Fehler genügend Informationen für eine Reproduktion hinterlässt.
Häufig gestellte Fragen
Sollte man bei Fehlern zuerst die parallele Testausführung abschalten?
Nein. Zuerst sollten Verzeichnisse, UserDefaults, Datenbanken und Ports pro Test isoliert werden. .serialized ist nur eine lokale Übergangslösung für nicht parallelisierbare Alttests.
Wie lässt sich ein Fehler reproduzieren, der nur in CI auftritt?
Seed, Worker-Anzahl, Testziel und xcresult-Pfad müssen pro Lauf gespeichert werden. Der fehlgeschlagene Ablauf wird anschließend mit identischem Seed und gleicher Parallelität wiederholt.
Warum sind feste Ports in parallelen Tests problematisch?
Mehrere Prozesse können denselben Port belegen oder den Dienst eines anderen Tests erreichen. Der Server sollte Port 0 anfordern und den tatsächlich vergebenen Port an den Client übergeben.
Wählen Sie einen exklusiven Cloud-Mac für kontinuierliche Build-Aufgaben
Prüfen Sie M4, Arbeitsspeicher, Speicherplatz, Standort und Abrechnungszeitraum und binden Sie die feste Build-Umgebung anschließend in Ihre bestehende Pipeline ein.