Stabilität bei großen Tests

Der asynchrone Charakter mobiler Anwendungen und Frameworks erschwert oft das Schreiben zuverlässiger und wiederholbarer Tests. Wenn ein Nutzerereignis eingefügt wird, muss das Test-Framework warten, bis die App vollständig darauf reagiert hat. Das kann von der Änderung eines Textes auf dem Bildschirm bis hin zur vollständigen Neuerstellung einer Aktivität reichen. Wenn ein Test kein deterministisches Verhalten aufweist, ist er unzuverlässig.

Moderne Frameworks wie Compose oder Espresso sind für Tests konzipiert. Es gibt also eine bestimmte Garantie, dass die UI vor der nächsten Testaktion oder Assertion inaktiv ist. Das ist die Synchronisierung.

Synchronisierung testen

Probleme können jedoch auftreten, wenn Sie asynchrone oder Hintergrundvorgänge ausführen, die für den Test nicht bekannt sind, z. B. das Laden von Daten aus einer Datenbank oder das Anzeigen von endlosen Animationen.

Flussdiagramm mit einer Schleife, die prüft, ob die App inaktiv ist, bevor ein Test bestanden wird
Abbildung 1: Testsynchronisierung

Sie können die Zuverlässigkeit Ihrer Testsuite erhöhen, indem Sie eine Möglichkeit zum Verfolgen von Hintergrundvorgängen wie Espresso Idling Resources installieren. Außerdem können Sie Module für Testversionen ersetzen, die Sie auf Inaktivität abfragen oder die die Synchronisierung verbessern können, z. B. TestDispatcher für Koroutinen oder RxIdler für RxJava.

Diagramm mit einem Testfehler, wenn die Synchronisierung auf dem Warten auf eine bestimmte Zeit basiert
Abbildung 2: Die Nutzung des Schlafs in Tests führt zu langsamen oder instabilen Tests.

Stabilität verbessern

Große Tests können viele Regressionen gleichzeitig erkennen, da dabei mehrere Komponenten einer App getestet werden. Sie werden in der Regel auf Emulatoren oder Geräten ausgeführt, wodurch sie eine hohe Genauigkeit bieten. Große End-to-End-Tests bieten zwar eine umfassende Abdeckung, sind aber anfälliger für gelegentliche Fehler.

Die wichtigsten Maßnahmen, die Sie ergreifen können, um die Instabilität zu reduzieren, sind:

  • Geräte richtig konfigurieren
  • Synchronisierungsprobleme vermeiden
  • Wiederholungsversuche implementieren

Wenn Sie große Tests mit Compose oder Espresso erstellen möchten, starten Sie in der Regel eine Ihrer Aktivitäten und navigieren Sie wie Nutzer. Dabei prüfen Sie mit Assertions oder Screenshottests, ob die Benutzeroberfläche korrekt funktioniert.

Andere Frameworks wie UI-Automator bieten mehr Möglichkeiten, da Sie mit der Systemoberfläche und anderen Apps interagieren können. UI-Automator-Tests erfordern jedoch möglicherweise mehr manuelle Synchronisierung und sind daher in der Regel weniger zuverlässig.

Geräte konfigurieren

Um die Zuverlässigkeit Ihrer Tests zu verbessern, sollten Sie zuerst dafür sorgen, dass das Betriebssystem des Geräts die Ausführung der Tests nicht unerwartet unterbricht. Beispielsweise, wenn ein Dialogfeld für das Systemupdate über anderen Apps angezeigt wird oder nicht genügend Speicherplatz auf dem Laufwerk vorhanden ist.

Anbieter von Device Farms konfigurieren ihre Geräte und Emulatoren, sodass Sie normalerweise nichts unternehmen müssen. Für Sonderfälle können sie jedoch eigene Konfigurationsanweisungen haben.

Von Gradle verwaltete Geräte

Wenn Sie die Emulatoren selbst verwalten, können Sie mit von Gradle verwalteten Geräten festlegen, welche Geräte zum Ausführen Ihrer Tests verwendet werden sollen:

android {
  testOptions {
    managedDevices {
      localDevices {
        create("pixel2api30") {
          // Use device profiles you typically see in Android Studio.
          device = "Pixel 2"
          // Use only API levels 27 and higher.
          apiLevel = 30
          // To include Google services, use "google".
          systemImageSource = "aosp"
        }
      }
    }
  }
}

Bei dieser Konfiguration wird mit dem folgenden Befehl ein Emulator-Image erstellt, eine Instanz gestartet, die Tests ausgeführt und sie herunterfahren.

./gradlew pixel2api30DebugAndroidTest

Gradle-verwaltete Geräte enthalten Mechanismen, die bei Geräteunterbrechungen einen erneuten Versuch starten und andere Verbesserungen umfassen.

Synchronisierungsprobleme vermeiden

Komponenten, die Hintergrund- oder asynchrone Vorgänge ausführen, können zu Testfehlern führen, weil eine Testanweisung ausgeführt wurde, bevor die Benutzeroberfläche dafür bereit war. Je größer der Umfang eines Tests ist, desto höher ist die Wahrscheinlichkeit, dass er instabil wird. Diese Synchronisierungsprobleme sind eine Hauptursache für Instabilitäten, da die Test-Frameworks ermitteln müssen, ob eine Aktivität vollständig geladen ist oder ob noch gewartet werden sollte.

Lösungen

Sie können die inaktiven Ressourcen von Espresso verwenden, um anzuzeigen, wann eine Anwendung ausgelastet ist. Allerdings ist es schwierig, jeden asynchronen Vorgang zu verfolgen, insbesondere bei sehr großen End-to-End-Tests. Außerdem ist es schwierig, Ressourcen im Leerlauf zu installieren, ohne den zu testenden Code zu verunreinigen.

Anstatt abzuschätzen, ob eine Aktivität ausgelastet ist oder nicht, können Sie dafür sorgen, dass Ihre Tests warten, bis bestimmte Bedingungen erfüllt sind. Sie können beispielsweise warten, bis ein bestimmter Text oder eine bestimmte Komponente in der Benutzeroberfläche angezeigt wird.

Compose bietet im Rahmen der ComposeTestRule eine Reihe von Test-APIs, mit denen auf verschiedene Matcher gewartet werden kann:

fun waitUntilAtLeastOneExists(matcher: SemanticsMatcher, timeout: Long = 1000L)

fun waitUntilDoesNotExist(matcher: SemanticsMatcher, timeout: Long = 1000L)

fun waitUntilExactlyOneExists(matcher: SemanticsMatcher,  timeout: Long = 1000L)

fun waitUntilNodeCount(matcher: SemanticsMatcher, count: Int, timeout: Long = 1000L)

Und eine generische API, die jede Funktion akzeptiert, die einen booleschen Wert zurückgibt:

fun waitUntil(timeoutMillis: Long, condition: () -> Boolean): Unit

Verwendungsbeispiele:

composeTestRule.waitUntilExactlyOneExists(hasText("Continue")</code>)</p></td>

Wiederholungsmechanismen

Sie sollten fehlerhafte Tests korrigieren. Manchmal sind die Bedingungen, die zu einem Fehler führen, jedoch so unwahrscheinlich, dass sie schwer zu reproduzieren sind. Sie sollten zwar immer fehlerhafte Tests im Auge behalten und beheben, aber ein Mechanismus zum Wiederholen kann die Produktivität der Entwickler aufrechterhalten, indem der Test so oft ausgeführt wird, bis er bestanden ist.

Wiederholungen müssen auf mehreren Ebenen erfolgen, um Probleme zu vermeiden, z. B.:

  • Die Verbindung zum Gerät ist abgelaufen oder die Verbindung wurde unterbrochen
  • Einzelner Testfehler

Die Installation oder Konfiguration von Wiederholungsversuchen hängt von Ihren Test-Frameworks und Ihrer Testinfrastruktur ab. Typische Mechanismen sind jedoch:

  • JUnit-Regel, die einen Test mehrmals wiederholt
  • Eine Aktion oder ein Schritt zum Wiederholen in Ihrem CI-Workflow
  • Ein System zum Neustarten eines Emulators, wenn er nicht mehr reagiert, z. B. von Gradle verwaltete Geräte.