UI-Tests bestehen auf dem Rechner eines Entwicklers, bleiben nach Dutzenden Durchläufen auf einem Cloud-Mac jedoch gelegentlich beim Onboarding hängen. Die Ursache liegt häufig nicht in der Simulatorleistung, sondern darin, dass der Startzustand nicht als verbindlicher Vertrag definiert wurde. Ein Test übergibt -skipOnboarding, ein anderer verwendet stattdessen eine Umgebungsvariable, und ein dritter vergisst, persistente Daten zu löschen. Einzeln funktionieren alle Tests plausibel, doch bei paralleler Ausführung oder geänderter Reihenfolge beeinflussen sie sich gegenseitig. Die Lösung besteht darin, Parameternamen, Werteformate, Zustandsresets und Ausschlussbedingungen für Release-Builds als gemeinsame, testbare Schnittstelle zu behandeln.
Zuerst den Startvertrag definieren, statt Zeichenketten zu verteilen
Startargumente eignen sich für boolesche Schalter, Umgebungsvariablen für Konfigurationen mit Werten. Beides sollte zentral deklariert werden, damit Test- und App-Target nicht jeweils eigene Schreibweisen verwenden. Der folgende Parser ist ausschließlich in Debug-Builds aktiv und nutzt für fehlende oder leere Werte eindeutig festgelegte Standardwerte.
#if DEBUG
enum UITestLaunchContract {
static let arguments = ProcessInfo.processInfo.arguments
static let environment = ProcessInfo.processInfo.environment
static var enabled: Bool {
arguments.contains("-ui-testing")
}
static var resetState: Bool {
arguments.contains("-reset-state")
}
static var fixture: String? {
guard enabled else { return nil }
return environment["UITEST_FIXTURE"]?.trimmingCharacters(
in: .whitespacesAndNewlines
).nonEmpty
}
}
private extension String {
var nonEmpty: String? {
isEmpty ? nil : self
}
}
#endif
Der Vertrag muss mindestens vier Fragen beantworten: Wie heißt der Schalter, woher wird sein Wert gelesen, wie werden ungültige Werte behandelt und was geschieht bei einem Start außerhalb der Tests? Ein fehlender Wert darf nicht implizit einem bestimmten fachlichen Zustand entsprechen. Zugangsdaten, Signaturmaterial oder Token gehören ebenfalls nicht in Umgebungsvariablen, da sie in Prozessinformationen, Testberichten oder Fehlerprotokollen auftauchen können.
Der Testeinstiegspunkt soll einen reproduzierbaren Zustand herstellen und keine fachlichen Prüfungen umgehen. Assertions, die sich über die öffentliche Benutzeroberfläche ausführen lassen, sollten weiterhin diesen Weg nehmen. Nur das Initialisieren von Fixtures und das Bereinigen von Restdaten gehören in den Startvertrag.
Zustand frühzeitig beim App-Start isolieren
Der Zustandsreset muss erfolgen, bevor Benutzereinstellungen gelesen, Datenbankcontainer erstellt und der erste Bildschirm ausgewählt werden. Wird zunächst die Oberfläche aufgebaut und anschließend asynchron bereinigt, beobachtet der Test gleichzeitig den alten und den neuen Zustand. Dadurch entsteht eine schwer reproduzierbare Race Condition.
Nur Daten löschen, die dem Test gehören
Verwenden Sie für UI-Tests eine eigene Suite, statt Verzeichnisse ohne klar definierte Grenzen zu löschen. Auch die Datenbank sollte in einem testspezifischen Container liegen und vor dem Erstellen des Persistenz-Stacks entfernt werden.
#if DEBUG
func prepareUITestState() throws {
guard UITestLaunchContract.enabled else { return }
if UITestLaunchContract.resetState {
let defaults = UserDefaults(suiteName: "com.example.app.uitests")
defaults?.removePersistentDomain(
forName: "com.example.app.uitests"
)
let storeURL = FileManager.default.temporaryDirectory
.appendingPathComponent("UITestStore.sqlite")
if FileManager.default.fileExists(atPath: storeURL.path) {
try FileManager.default.removeItem(at: storeURL)
}
}
}
#endif
Die Bereinigungsfunktion muss wiederholt ausführbar sein: Ein nicht vorhandenes Ziel darf keinen Fehler verursachen. Schlägt das Löschen dagegen fehl, muss der Teststart sofort abgebrochen werden, statt mit einem nur teilweise bereinigten Zustand fortzufahren. Fixture-Namen sollten außerdem über eine Positivliste wie empty-project oder three-items aufgelöst werden. Eine Umgebungsvariable darf niemals direkt als beliebiger Dateipfad verwendet werden.
Startargumente zentral in XCTest zusammenstellen
Auf Testseite wird ausschließlich eine zentrale Start-Factory aufgerufen. Sie beendet alte Prozesse, setzt die festgelegten Parameter und validiert das Fixture. Einzelne Testmethoden verändern launchArguments nicht mehr selbst.
final class AppLauncher {
static func launch(fixture: String) -> XCUIApplication {
let allowed = ["empty-project", "three-items"]
precondition(allowed.contains(fixture))
let app = XCUIApplication()
if app.state != .notRunning {
app.terminate()
}
app.launchArguments = [
"-ui-testing",
"-reset-state",
"-disable-animations"
]
app.launchEnvironment = [
"UITEST_FIXTURE": fixture,
"LC_ALL": "en_US_POSIX"
]
app.launch()
return app
}
}
launchArguments sollte immer vollständig zugewiesen werden. Vermeiden Sie auf einer gemeinsam genutzten App-Instanz mehrere aufeinanderfolgende append-Aufrufe. Durch die vollständige Zuweisung bleiben bei einer erneuten Ausführung keine Schalter aus dem vorherigen Testfall zurück. Benötigt ein Test zwei unterschiedliche Zustände, muss der Prozess beendet und die Factory erneut aufgerufen werden. Umgebungsvariablen während der Ausführung zu ändern genügt nicht, denn nach dem Prozessstart wird ProcessInfo nicht mit den neuen Werten des Testskripts initialisiert.
Vertragsverletzungen als Pipeline-Fehler behandeln
Code-Reviews allein verhindern nicht, dass veraltete Parameter weiterverwendet werden. Ein kleiner Unit-Test kann zusätzlich prüfen, ob Parameternamen eindeutig sind und sich alle Fixtures auflösen lassen. Ein UI-Smoke-Test sollte außerdem drei Pfade abdecken: normaler Start ohne Parameter, Start mit einem gültigen Fixture auf dem erwarteten ersten Bildschirm und eindeutiger Fehler bei einem ungültigen Fixture.
Fixieren Sie vor der Ausführung auf dem Cloud-Mac zunächst Scheme, Testplan und Simulator-ID:
set -euo pipefail
: "${SIMULATOR_ID:?SIMULATOR_ID is required}"
xcodebuild test \
-workspace App.xcworkspace \
-scheme App \
-testPlan CI \
-destination "platform=iOS Simulator,id=${SIMULATOR_ID}" \
-resultBundlePath artifacts/LaunchContract.xcresult
Die Pipeline darf einen ersten Fehler nicht durch „noch einmal ausführen“ verbergen. Das Ergebnis-Bundle des ersten fehlgeschlagenen Laufs muss unverändert erhalten bleiben. Wiederholungen dürfen nur der Diagnose dienen und müssen einen neuen Ausgabepfad verwenden. Andernfalls überschreibt ein erfolgreicher zweiter Lauf die wertvollsten Informationen zum ursprünglichen Startzustand.
Kompilierungsbedingungen für Release prüfen
Wenn der Testeinstiegspunkt mit #if DEBUG geschützt ist, muss zusätzlich sichergestellt werden, dass Release-Builds weder DEBUG noch benutzerdefinierte Testbedingungen versehentlich enthalten:
set -euo pipefail
settings="$(
xcodebuild \
-workspace App.xcworkspace \
-scheme App \
-configuration Release \
-showBuildSettings
)"
if printf '%s\n' "$settings" |
grep -E 'SWIFT_ACTIVE_COMPILATION_CONDITIONS.*(DEBUG|UI_TESTING)'; then
echo "Unexpected test compilation condition in Release" >&2
exit 1
fi
Diese Prüfung arbeitet mit den endgültig aufgelösten Build-Einstellungen und ist daher zuverlässiger als eine bloße Kontrolle der Projektdatei. Verwendet das Team mehrere xcconfig-Dateien, muss sie für jede veröffentlichungsfähige Configuration separat ausgeführt werden.
Parallele Ausführung und Fehlerdaten handhaben
Bei parallelen Tests benötigt jeder Worker eine eigene Datenkennung. Die Pipeline kann eine nicht vertrauliche UITEST_RUN_ID erzeugen, aus der temporäre Verzeichnisse und Suite-Namen abgeleitet werden. Mehrere Worker dürfen keine feste Datenbankdatei gemeinsam verwenden. Nach Abschluss des Tests darf nur das Verzeichnis gelöscht werden, das zur Kennung des aktuellen Laufs gehört. So entfernt ein Job nicht versehentlich die Fehlerdaten eines anderen Jobs.
Als Fehlerdaten sollten mindestens die Positivliste der Startargumente, der Fixture-Name, die Testmethode, die Simulator-ID und der Pfad zum Ergebnis-Bundle erhalten bleiben. Geben Sie nicht das vollständige Wörterbuch der Umgebungsvariablen im Protokoll aus, da es vertrauliche Werte enthalten kann, die von der Pipeline injiziert wurden. Protokollieren Sie ausschließlich ausdrücklich zugelassene Schlüssel und maskieren Sie deren Werte typgerecht.
Für die Fehlersuche kann eine feste Reihenfolge verwendet werden:
- Prüfen, ob die App vor dem Start tatsächlich beendet wurde.
- Prüfen, ob die Parameter vollständig zugewiesen wurden und keine doppelten Schlüssel enthalten.
- Sicherstellen, dass die Zustandsbereinigung vor der Initialisierung der Datenbank und der Root-Oberfläche erfolgt.
- Die Datenverzeichnisse des fehlgeschlagenen und des vorherigen Testfalls vergleichen.
- Abstürze, Assertions und die zeitliche Abfolge der Screenshots im selben Ergebnis-Bundle untersuchen.
- Einen Kaltstart ohne Testparameter ausführen und prüfen, ob der normale Einstieg unverändert funktioniert.
Minimale Abnahmecheckliste vor dem Release
Vor dem Zusammenführen einer Änderung müssen alle folgenden Bedingungen erfüllt sein: Parameternamen sind zentral definiert; Konfigurationen mit Werten verwenden eine Positivliste; Testdaten liegen in einem eigenen Container; Bereinigungsoperationen sind wiederholbar; die Parameter werden bei jedem Start vollständig überschrieben; parallele Worker teilen keine Verzeichnisse; fehlgeschlagene Ergebnis-Bundles werden nicht durch Wiederholungen überschrieben; Release-Kompilierungsbedingungen enthalten keine Testkennzeichnungen; und ein Kaltstart ohne Parameter führt in den normalen Ablauf.
Der Nutzen dieser Regeln liegt nicht darin, lediglich eine weitere Start-Factory zu schreiben. Entscheidend ist, die implizite Vereinbarung im Testskript darüber, „in welchem Zustand die App startet“, in eine Schnittstelle zu verwandeln, die sowohl die App als auch die Pipeline validieren können. Erst wenn sich Zustände beschreiben, ablehnen und bereinigen lassen, liefern Tests auf Cloud-Macs auch bei geänderter Reihenfolge, paralleler Ausführung und langfristig wiederholten Läufen vergleichbare Ergebnisse.
Häufig gestellte Fragen
Warum sollten Startargumente nicht in jedem UI-Test frei ergänzt werden?
Verstreute Zeichenketten verursachen abweichende Schreibweisen, Reihenfolgeabhängigkeiten und veraltete Flags. Namen, Werteformate und Standardverhalten gehören in einen gemeinsam genutzten Typ.
Wie weist CI nach, dass Test-Hooks nicht im Release-Build enthalten sind?
Kompilieren Sie die Implementierung ausschließlich unter DEBUG, prüfen Sie die Release-Bedingungen und Build-Einstellungen und führen Sie zusätzlich einen Kaltstart ohne Testargumente aus.
Geprüfte Workflows auf exklusiven physischen Knoten ausführen
Wählen Sie je nach Aufgabe M4 oder M4 Pro, den Zielknoten und den Abrechnungszeitraum. Prüfen Sie Konfiguration und Zusatzoptionen vor der Bestellung.