Lernziel
Nach dieser Lektion kannst du UI-Tests schreiben, die deine App wie ein Nutzer bedienen: ein UI-Test-Target anlegen, Elemente über Accessibility-Bezeichner finden, tippen, tippen lassen und wischen, auf Elemente warten, die App mit Startargumenten in einen definierten Zustand versetzen und Screenshots anhängen. Du weißt, welche Abläufe sich für UI-Tests lohnen.
Das Problem: Alles einzeln richtig, zusammen kaputt
Deine Unit-Tests aus den Lektionen 4.10 und 4.11 sind grün. Die Summen stimmen, die Deep Links werden erkannt, das Modell lädt korrekt. Und trotzdem: Ein Nutzer meldet, dass der „Sichern“-Button im Formular nichts tut.
Was ist passiert? Vielleicht verdeckt die Tastatur den Button. Vielleicht ist er versehentlich .disabled. Vielleicht schließt das Sheet, aber die Ausgabe wird nicht eingefügt. Unit-Tests prüfen einzelne Bausteine. Ob die Bausteine zusammen auf dem Bildschirm funktionieren, prüfen sie nicht.
Dafür gibt es UI-Tests: Ein Testprogramm startet deine App und bedient sie wie ein Mensch. Es tippt auf Buttons, gibt Text ein, wischt und prüft, was danach auf dem Bildschirm steht.
Wie UI-Tests funktionieren
UI-Tests laufen in einem eigenen Prozess, getrennt von deiner App. Sie sehen die App von außen, genau wie VoiceOver: über die Accessibility-Informationen jedes Elements, also Beschriftung, Rolle (Button, Textfeld …) und Wert.
Das hat eine wichtige Folge: Eine App, die gut mit VoiceOver funktioniert, ist auch gut testbar. Und umgekehrt: Wenn ein UI-Test ein Element nicht findet, findet VoiceOver es oft auch nicht (Lektion 4.16).
UI-Tests werden mit XCTest geschrieben, dem älteren Test-Framework. Swift Testing (Lektion 4.10) ist für Unit-Tests gedacht, für die Bedienung der Oberfläche bleibt XCTest das Werkzeug.
Das UI-Test-Target
- Datei → Neu → Target …
- Wähle UI Testing Bundle.
- Bei Target to be Tested wähle deine App.
Es entsteht ein Ordner wie AusgabenUITests mit einer Vorlage. Das Grundgerüst:
import XCTest
final class AusgabenUITests: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = false
}
@MainActor
func testExample() throws {
let app = XCUIApplication()
app.launch()
}
}- Eine
XCTestCase-Klasse fasst Tests zusammen. Jede Methode, deren Name mittestbeginnt, ist ein Test. continueAfterFailure = false: Beim ersten Fehler abbrechen. Bei UI-Tests sinnvoll, weil nach einem Fehler meist alle weiteren Schritte ins Leere laufen.XCUIApplication()steht für deine App,app.launch()startet sie.
Elemente finden
Die App bietet Abfragen für jede Art von Element:
app.buttons["Sichern"] // Button mit dieser Beschriftung
app.textFields["Wofür?"] // Textfeld mit diesem Platzhalter
app.staticTexts["Ausgaben"] // ein Text
app.navigationBars["Ausgaben"] // eine Navigationsleiste
app.tabBars.buttons["Auswertung"] // ein Tab
app.cells.firstMatch // die erste Listenzeile
app.switches["Benachrichtigungen"]Die Zeichenkette in den eckigen Klammern ist der Bezeichner: entweder der sichtbare Text bzw. die Accessibility-Beschriftung oder ein eigener Accessibility-Bezeichner.
Eigene Bezeichner: accessibilityIdentifier
Elemente über ihren sichtbaren Text zu finden, ist zerbrechlich. Ändert sich der Text oder übersetzt du die App (Lektion 4.17), findet der Test nichts mehr. Besser sind feste Bezeichner, die der Nutzer nie sieht:
Button("Sichern") { save() }
.accessibilityIdentifier("expenseForm.save")
TextField("Wofür?", text: $title)
.accessibilityIdentifier("expenseForm.title")
Text(monthTotal, format: .currency(code: "EUR"))
.accessibilityIdentifier("expenseList.monthTotal")Im Test:
app.buttons["expenseForm.save"]
app.textFields["expenseForm.title"]
app.staticTexts["expenseList.monthTotal"]Ein Namensschema
Ein Schema wie bildschirm.element hält Bezeichner eindeutig und lesbar. Leg sie gern als Konstanten an, die App und Tests teilen, etwa in einem Enum A11yID. Tippfehler fallen dann beim Kompilieren auf.
Interagieren
app.buttons["expenseList.add"].tap() // tippen
app.textFields["expenseForm.title"].tap()
app.textFields["expenseForm.title"].typeText("Kaffee") // Text eingeben
app.cells.firstMatch.swipeLeft() // wischen
app.buttons["Löschen"].tap()
app.swipeDown() // auf der ganzen App wischen
app.cells.firstMatch.press(forDuration: 1.0) // lange drückenBevor du Text eingibst, muss das Feld den Fokus haben: zuerst tap(), dann typeText.
Prüfen
Prüfungen machst du mit den XCTAssert-Funktionen:
XCTAssertTrue(app.staticTexts["Kaffee"].exists)
XCTAssertEqual(app.cells.count, 3)
XCTAssertFalse(app.buttons["expenseForm.save"].isEnabled)
XCTAssertEqual(app.staticTexts["expenseList.monthTotal"].label, "3,50 €").exists: Gibt es das Element?.isEnabled: Ist es aktiv?.isHittable: Ist es sichtbar und antippbar, also nicht verdeckt?.label: Die Beschriftung, bei Texten der angezeigte Text..value: Der Wert, etwa der Inhalt eines Textfelds oder der Zustand eines Schalters.
Warten
Apps sind nicht sofort fertig: Sheets fahren hoch, Daten laden, Animationen laufen. Prüft der Test zu früh, findet er das Element noch nicht. Deshalb wartest du ausdrücklich:
let saveButton = app.buttons["expenseForm.save"]
XCTAssertTrue(saveButton.waitForExistence(timeout: 2))
saveButton.tap()waitForExistence(timeout:) wartet bis zu zwei Sekunden, bis das Element erscheint, und gibt true zurück, sobald es da ist. Erscheint es nicht, gibt es false zurück, und der Test schlägt fehl.
Nie mit festen Pausen
sleep(3) macht Tests langsam und trotzdem unzuverlässig: Auf einem langsamen Rechner reichen drei Sekunden vielleicht nicht, auf einem schnellen wartest du unnötig. Warte immer auf eine Bedingung, nicht auf eine Zeit.
Für das Verschwinden eines Elements gibt es in aktuellen Xcode-Versionen waitForNonExistence(timeout:).
Ein vollständiger Test
Jetzt der Ablauf vom Anfang: Eine Ausgabe anlegen und prüfen, dass sie in der Liste erscheint.
@MainActor
func testAddExpense() throws {
let app = XCUIApplication()
app.launchArguments = ["-uiTesting"]
app.launch()
// Formular öffnen
app.buttons["expenseList.add"].tap()
// Titel eingeben
let title = app.textFields["expenseForm.title"]
XCTAssertTrue(title.waitForExistence(timeout: 2))
title.tap()
title.typeText("Kaffee")
// Betrag eingeben
let amount = app.textFields["expenseForm.amount"]
amount.tap()
amount.typeText("3,50")
// Sichern
app.buttons["expenseForm.save"].tap()
// Prüfen
XCTAssertTrue(app.staticTexts["Kaffee"].waitForExistence(timeout: 2))
XCTAssertEqual(app.staticTexts["expenseList.monthTotal"].label, "3,50 €")
}Liest sich fast wie eine Anleitung für einen Menschen. Und genau so soll ein guter UI-Test sein.
Einen definierten Zustand herstellen
Im Test oben steht app.launchArguments = ["-uiTesting"]. Warum?
Ein UI-Test startet die echte App mit ihrer echten Datenbank. Hat der letzte Testlauf schon drei Ausgaben angelegt, stimmt die Monatssumme nicht mehr, und der Test schlägt fehl. UI-Tests brauchen deshalb bei jedem Start einen bekannten Ausgangszustand.
Die Lösung: Die App erkennt beim Start, dass sie getestet wird, und verwendet dann eine leere Datenbank im Arbeitsspeicher.
@main
struct AusgabenApp: App {
let container: ModelContainer
init() {
let isUITesting = CommandLine.arguments.contains("-uiTesting")
let configuration = ModelConfiguration(isStoredInMemoryOnly: isUITesting)
do {
container = try ModelContainer(for: Expense.self, Category.self, configurations: configuration)
} catch {
fatalError("Datenbank konnte nicht geöffnet werden: \(error)")
}
}
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(container)
}
}CommandLine.argumentsenthält die Startargumente der App, auch die auslaunchArgumentsim Test.- Beim UI-Test liegt die Datenbank nur im Arbeitsspeicher und ist bei jedem Start leer. Der Nutzer merkt davon nichts.
Auf dieselbe Weise kannst du weitere Zustände herstellen: -uiTestingWithSampleData könnte die Beispieldaten aus Projekt E einfügen, -skipOnboarding das Onboarding überspringen. Für Werte statt Schalter gibt es launchEnvironment, ein Dictionary, das die App über ProcessInfo.processInfo.environment liest.
Tipp
Ein fatalError ist hier vertretbar: Ohne Datenbank kann die App nicht sinnvoll laufen. In einer veröffentlichten App würdest du stattdessen einen Fehlerbildschirm zeigen.
Tests aufzeichnen
Xcode kann deine Interaktionen auch aufzeichnen und daraus Code erzeugen:
- Setz den Cursor in eine leere Testmethode.
- Klick unten in der Debug-Leiste auf den roten Aufnahme-Knopf.
- Bediene die App im Simulator.
- Stopp die Aufnahme.
Xcode schreibt den Code für jeden Tipp und jede Eingabe. Das ist ein guter Startpunkt, um die richtigen Abfragen zu finden. Den erzeugten Code solltest du aber aufräumen: Ersetze lange Abfragen durch Bezeichner und füge Wartebedingungen und Prüfungen hinzu.
Screenshots anhängen
Schlägt ein UI-Test fehl, ist ein Bild oft mehr wert als jede Fehlermeldung. Du kannst jederzeit einen Screenshot an das Testergebnis hängen:
let screenshot = app.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.name = "Nach dem Speichern"
attachment.lifetime = .keepAlways
add(attachment)Im Report Navigator (⌘ 9) findest du die Bilder beim jeweiligen Testlauf. Xcode hängt bei Fehlschlägen außerdem automatisch Screenshots an.
Was sich für UI-Tests lohnt
UI-Tests sind langsam (Sekunden statt Millisekunden), und sie sind empfindlicher: Eine Änderung am Aussehen kann sie brechen, ohne dass die App kaputt ist. Deshalb gilt:
- Wenige, wichtige Abläufe: die Wege, die immer funktionieren müssen. Im Ausgaben-Tracker etwa: Ausgabe anlegen, bearbeiten, löschen, Auswertung öffnen.
- Keine Details: Ob eine Berechnung bei allen Randfällen stimmt, prüfen Unit-Tests viel schneller und genauer.
- Feste Bezeichner statt sichtbarer Texte, damit Tests Design- und Textänderungen überstehen.
Typische Fehler
Element wird nicht gefunden
- Der Bezeichner stimmt nicht. Mit
print(app.debugDescription)siehst du im Testlauf alle Elemente der aktuellen Oberfläche mit ihren Bezeichnern. - Das Element ist noch nicht da. Warte mit
waitForExistence. - Mehrere Elemente sind zu einem zusammengefasst, etwa durch
.accessibilityElement(children: .combine). Setz den Bezeichner dann an das zusammengefasste Element.
Tests schlagen abwechselnd fehl und gelingen
Feste Pausen statt Wartebedingungen, oder der Ausgangszustand ist nicht definiert. Verwende waitForExistence und Startargumente für eine leere Datenbank.
Texteingabe landet im falschen Feld
Das Feld hatte keinen Fokus. Erst tap(), dann typeText.
Tastatur verdeckt den Button
Im Simulator kann die Software-Tastatur Elemente verdecken. Prüfe isHittable oder schließ die Tastatur, etwa durch Tippen auf einen Bereich außerhalb oder mit einem „Fertig“-Button in der Tastatur-Toolbar.
FAQ
Anfänger
Was bedeutet das überhaupt?Laufen UI-Tests auf dem echten iPhone? Ja, wenn es angeschlossen und als Ziel gewählt ist. Meist lässt man sie aber im Simulator laufen, weil das bequemer ist und sich leicht automatisieren lässt.
Kann ich UI-Tests und Unit-Tests zusammen ausführen? Ja, ⌘ U führt alle Tests des Schemas aus. Weil UI-Tests langsamer sind, kannst du im Test Navigator auch gezielt nur Unit-Tests starten, während du entwickelst.
Fortgeschritten
Warum verhält es sich so?Wie gehe ich mit Systemdialogen um, etwa der Frage nach Benachrichtigungen?
Systemdialoge gehören nicht zu deiner App. Mit addUIInterruptionMonitor(withDescription:) kannst du einen Handler registrieren, der solche Dialoge bestätigt. Einfacher ist es oft, im UI-Test-Modus gar nicht erst nach Berechtigungen zu fragen.
Wie teste ich die App in verschiedenen Sprachen?
Über Startargumente: app.launchArguments += ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] startet die App auf Englisch. Mit festen Bezeichnern laufen dieselben Tests in jeder Sprache (Lektion 4.17).
Profi
Was passiert unter der Haube?Wie misst man mit UI-Tests die Leistung?
Mit measure(metrics:) und Metriken wie XCTApplicationLaunchMetric für die Startzeit oder XCTOSSignpostMetric.scrollDecelerationMetric für Ruckler beim Scrollen. Xcode mittelt über mehrere Läufe und warnt, wenn ein Wert schlechter wird als eine gespeicherte Grundlinie.
Warum @MainActor an den Testmethoden?
Die XCUITest-APIs sind auf dem Main Actor isoliert. Die Xcode-Vorlage markiert die Testmethoden deshalb mit @MainActor. Unter Swift 6 meldet der Compiler sonst Fehler beim Zugriff auf XCUIApplication.
Übungen
Übung 1: Bezeichner vergeben
Vergib im Ausgaben-Tracker aus Projekt E feste accessibilityIdentifier für den Plus-Button, die Felder und den Sichern-Button des Formulars sowie die Monatssumme. Starte einen UI-Test mit print(app.debugDescription) und finde deine Bezeichner in der Ausgabe.
Übung 2: Anlegen und Löschen
Schreib einen UI-Test, der zwei Ausgaben anlegt, prüft, dass beide in der Liste stehen, eine davon per Wischen löscht und prüft, dass nur noch eine übrig ist. Die App startet dafür mit einer leeren Datenbank im Arbeitsspeicher.
Lösung Übung 2Erst selbst probieren!
@MainActor
func testAddAndDelete() throws {
let app = XCUIApplication()
app.launchArguments = ["-uiTesting"]
app.launch()
func addExpense(_ name: String, amount: String) {
app.buttons["expenseList.add"].tap()
let title = app.textFields["expenseForm.title"]
XCTAssertTrue(title.waitForExistence(timeout: 2))
title.tap()
title.typeText(name)
let amountField = app.textFields["expenseForm.amount"]
amountField.tap()
amountField.typeText(amount)
app.buttons["expenseForm.save"].tap()
XCTAssertTrue(app.staticTexts[name].waitForExistence(timeout: 2))
}
addExpense("Kaffee", amount: "3,50")
addExpense("Brot", amount: "2,80")
app.staticTexts["Kaffee"].swipeLeft()
app.buttons["Löschen"].tap()
XCTAssertTrue(app.staticTexts["Kaffee"].waitForNonExistence(timeout: 2))
XCTAssertTrue(app.staticTexts["Brot"].exists)
}Eine lokale Hilfsfunktion addExpense vermeidet doppelten Code. Der Lösch-Button heißt im System „Löschen“, weil die App auf Deutsch läuft. Für Tests in mehreren Sprachen würdest du die Sprache per Startargument festlegen.
Übung 3: Screenshots für den App Store
Schreib einen UI-Test, der die App mit Beispieldaten startet (-uiTestingWithSampleData), nacheinander die Liste, das Formular und die Auswertung öffnet und von jedem Bildschirm einen Screenshot mit sprechendem Namen anhängt. Lass den Test in einem großen und einem kleinen Simulator laufen. So entstehen in Zukunft deine App-Store-Bilder automatisch (Lektion 4.22).
Zusammenfassung
- UI-Tests starten die echte App und bedienen sie wie ein Nutzer. Sie prüfen, ob die Bausteine zusammen funktionieren.
- Sie laufen mit XCTest in einem eigenen UI Testing Bundle und sehen die App über ihre Accessibility-Informationen.
- Elemente finden über
app.buttons["…"]und Co., am besten mit festen **accessibilityIdentifier**s. - Interagieren mit
tap(),typeText,swipeLeft(), prüfen mitXCTAssert…. - Auf Bedingungen warten mit
waitForExistence, nie mit festen Pausen. - Startargumente stellen einen definierten Zustand her, etwa eine leere Datenbank im Arbeitsspeicher.
- Nur wenige, wichtige Abläufe als UI-Test, Details mit Unit-Tests.
Spickzettel
// App
Button("Sichern") { ... }.accessibilityIdentifier("form.save")
let isUITesting = CommandLine.arguments.contains("-uiTesting")
// UI-Test
import XCTest
final class AppUITests: XCTestCase {
override func setUpWithError() throws { continueAfterFailure = false }
@MainActor
func testFlow() throws {
let app = XCUIApplication()
app.launchArguments = ["-uiTesting"]
app.launch()
app.buttons["list.add"].tap()
let field = app.textFields["form.title"]
XCTAssertTrue(field.waitForExistence(timeout: 2))
field.tap(); field.typeText("Kaffee")
app.buttons["form.save"].tap()
XCTAssertTrue(app.staticTexts["Kaffee"].waitForExistence(timeout: 2))
add(XCTAttachment(screenshot: app.screenshot()))
}
}
// print(app.debugDescription) zeigt alle Elemente