Neben den Unit Tests gibt es noch eine weitere Form der automatisierten Tests, die User Interface Tests, kurz UI Tests. Während
Unit Tests sich wirklich auf eine abgegrenzte Einheit oder Funktion konzentrieren, simulieren UI Tests das tatsächliche
Verhalten eines Nutzers, der mit der App interagiert. Dadurch sind allerdings auch deutlich mehr Dinge zu beachten, damit UI
Tests genau wie Unit Tests deterministisch sind, also bei jeder Durchführung zum gleichen Ergebnis führen.
Dies lässt sich gut am Beispiel einer iOS-App zeigen. In dieser Beispiel-App gibt es drei Funktionen, die sich alle mit UI Tests
testen lassen. Wird ein Text eingegeben, erscheint dieser nach Bestätigung in der Navigation Bar. Drückt man den Button,
erscheint ein Alert mit dem Titel „Good Morning“. Zuletzt lässt sich über den Switch einstellen, ob man den Button drücken kann
oder nicht.
Der erste UI-Test
Zum Schreiben eines UI Tests kann wie bei Unit Tests auf Apples XCTest Framework zurückgegriffen werden. Im Gegensatz zu Unit
Tests muss man in jedem Test Case allerdings die App starten und beschreiben, wie mit welchen Elementen interagiert werden
soll.
Das Starten der App geschieht folgendermaßen:
let app = XCUIApplication()
app.launch()
Auf die einzelnen UI-Elemente lässt sich nun über die Variable app zugreifen. Der Button im obigen Beispiel lässt sich
beispielsweise über den Titel auslesen.
let button = app.buttons["Show Alert"]
Der Nachteil dieser Art und Weise ist allerdings, dass man den UI Test aktualisieren müsste, wenn sich aus gewissen Gründen der
Titel des Buttons ändern würde. Um dies zu vermeiden, lässt sich auf jedem UIKit-Element der accessibilityIdentifier setzen, der
genau für UI Tests gedacht ist. Dies kann entweder im Code geschehen oder man setzt ihn im Storyboard bzw. Xib-File im Identity
Inspector der jeweiligen View. Analog dazu gibt es in SwiftUI den Modifier .accessibility(identifier:).
Gibt man dem Button nun den accessibilityIdentifier „Example.Button“, kann im UI Test über diesen String darauf zugegriffen
werden.
let button = app.buttons["Example.Button"]
Um gewisse Bedingungen zu testen verwendet man, wie auch in Unit Tests, die Assertions, welche XCTest anbietet. Damit
sichergestellt wird, dass der Button gedrückt werden kann, kann man wie folgt vorgehen:
XCTAssertTrue(button.isEnabled)
Im letzten Schritt unseres ersten UI Tests soll geprüft werden, dass der Button nach Deaktivieren des Switches nicht mehr
gedrückt werden kann. Dazu wird der Switch anhand seines vergebenen accessibilityIdentifier ausgelesen und einmal betätigt.
Schließlich folgt die Prüfung, ob der Button nicht mehr aktiv ist.
let uiSwitch = app.switches["Example.Switch"]
uiSwitch.tap()
XCTAssertFalse(button.isEnabled)
Xcode bietet auch die Möglichkeit an, bestimmte Interaktionen, die getestet werden sollen, aufzunehmen. Das bedeutet, man kann im
Simulator mit UI-Elementen interagieren und der Recorder übersetzt dies in Code für einen UI Test. In den meisten Fällen ist
dies nicht besonders zuverlässig und der Code ist meist komplizierter als notwendig, es kann jedoch als Einstiegshilfe dienen.
Eine Aufnahme kann über den roten Record-Button unterhalb des Editors gestartet werden, wenn sich der Cursor in einer
Testmethode befindet.
Alerts und Navigation Bar
Nach der Fertigstellung des ersten UI Tests können wir nach dem gleichen Muster auch für unsere beiden weiteren Szenarien
vorgehen.
Um zu testen, dass der Button ein Alert auslöst, wird dieser wieder ausgelesen und schließlich über die tap()-Methode gedrückt.
Ein Alert lässt sich mittels seines Titels auslesen, allerdings kann man hier auch auf die element-Property zurückgreifen, die
man stets nutzen kann, wenn es nur ein Element des entsprechenden Typs gibt. Wenn z. B. nur ein Button auf dem Screen zu sehen
ist, könnte man auch über app.buttons.element auf den Button zugreifen. Ähnlich funktioniert auch die firstElement property, die
das erste passende Element zurück liefert, wenn es beispielsweise keine Rolle spielt, welches konkrete Element des Typs benötigt
wird.
Nun können wir mittels der exists-Property sicherstellen, dass das Alert auch wirklich angezeigt wird und schließlich über den
OK-Button wieder schließen. So sieht das ganze am Ende so aus:
let app = XCUIApplication()
app.launch()
let button = app.buttons["Example.Button"]
button.tap()
let alert = app.alerts.element
XCTAssertTrue(alert.exists)
alert.buttons["OK"].tap()
Im letzten UI Test soll sichergestellt werden, dass der Text, welcher in das Textfeld eingegeben wird, nach Bestätigung in der
Navigation Bar angezeigt wird. Auch Textfelder lassen sich über die app-Variable auslesen. Bei der Eingabe des Texts sind ein
paar Dinge zu beachten. Zunächst muss das Textfeld den Fokus erhalten, indem wir es mittels tap() auswählen. Daraufhin lässt
sich mit der typeText()-Funktion ein beliebiger String eingeben. Damit der Text in die Navigation Bar geschrieben wird, muss der
Return-Button der Tastatur betätigt werden. An der Stelle ist es im UI Test extrem wichtig, im Simulator das Software Keyboard
aktiviert zu haben (I/O —> Keyboard —> Toggle Software Keyboard), da sonst der Return-Button nicht gefunden werden kann.
Die Navigation Bar kann schließlich ebenfalls über ihren Titel ausgelesen werden, woraufhin geprüft wird, ob diese auch wirklich
vorhanden ist.
let app = XCUIApplication()
app.launch()
let textField = app.textFields["Example.TextField"]
textField.tap()
textField.typeText("Good Morning")
app.keyboards.buttons["Return"].tap()
let navigationBar = app.navigationBars["Good Morning"]
XCTAssertTrue(navigationBar.exists)
Stolpersteine
Ein wichtiger Punkt, der bei UI Tests zu beachten ist, ist Flakiness. Man spricht davon, dass ein Test flaky ist, wenn er
manchmal erfolgreich durchläuft, ein anderes Mal aber nicht. Dies kann viele Gründe haben. Ein Grund kann sein, dass ein State
aus einem vorherigen Test nicht zurückgesetzt wurde und die App nicht im korrekten Zustand ist, sodass z. B. ein Onboarding
Screen, der nur beim ersten App Start angezeigt wird, beim 2. Lauf des UI Tests nicht mehr angezeigt wird.
Eine gängige Art und Weise, dies zu vermeiden, sind Launch Arguments. Diese können der App vor dem Start im UI-Test mitgegeben
und im Code der App ausgelesen werden. So kann z. B. auf diesem Weg definiert werden, dass wir die App bei jedem Durchlauf
zurücksetzen möchten:
let app = XCUIApplication()
app.launchArguments = ["reset-app"]
app.launch()
Im AppDelegate oder SceneDelegate kann daraufhin geprüft werden, ob das reset-Argument vorhanden ist und dann gegebenenfalls die
Datenbank oder UserDefaults zurückgesetzt werden, je nach App Szenario.
if CommandLine.arguments.contains("reset-app") {
resetApp()
}
Im Gegensatz zu Unit Tests, bei welchen Netzwerkaufrufe meist nicht wirklich ausgeführt werden, werden diese in UI Tests ganz
normal ausgeführt. Das kann allerdings ebenfalls dazu führen, dass Elemente, die z. B. erst nach Laden der Daten vom Server
angezeigt werden, im UI Test nicht direkt verfügbar sind.
Wird also ein Button erst angezeigt, nachdem eine Anfrage an den Server erfolgreich durchgeführt wurde, kann dies im UI Test
mittels eines Delays berücksichtigt werden. Über die Methode waitForExistence kann ein Timeout definiert werden, der im Grunde
aussagt, dass die Dauer des Timeouts abgewartet wird, dass das Element existiert. Erst wenn nach Ablauf des Timeouts die
Bedingung nicht erfüllt ist, gilt der Test als fehlgeschlagen.
XCTAssertTrue(timelineCell.waitForExistence(timeout: 2))
Launch Arguments und die waitForExistence-Methode sind sehr nützlich, um sicherzustellen, dass UI Tests deterministisch sind.
Erst, wenn jeder Durchlauf des Tests zum gleichen Ergebnis führt, ist er wirklich von Nutzen.
Wrapping Up
Wie gezeigt wurde, sind UI Tests ein sehr hilfreiches Mittel zur Qualitätssicherung einer App. Dabei können zum einen bestimmte
Workflows getestet werden, aber es lässt sich auch sicherstellen, dass die UI auf verschiedensten Gerätegrößen gut aussieht.
Zudem lässt sich im Grunde jede Interaktion und Geste des Benutzers abbilden. Lediglich WebViews oder Szenarien, bei welchen auf
die Sensoren des Geräts zugegriffen werden, sind weniger geeignet.
Da es mehr Aufwand benötigt, UI Tests zu schreiben und durchzuführen als z. B. Unit Tests, sollte man ein gewisses Gefühl dafür
entwickeln, an welchen Stellen der Einsatz Sinn macht.