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.