Stabilität bei großen Tests

Aufgrund der asynchronen Natur von mobilen Anwendungen und Frameworks ist es oft schwierig, zuverlässige und wiederholbare Tests zu schreiben. Wenn ein Nutzerereignis eingefügt wird, muss das Test-Framework warten, bis die App darauf reagiert hat. Das kann von der Änderung von Text 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 wurden speziell für Tests entwickelt. Daher ist es relativ sicher, dass die Benutzeroberfläche vor der nächsten Testaktion oder -aussage 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

Um die Zuverlässigkeit Ihrer Testsuite zu erhöhen, können Sie eine Möglichkeit zum Überwachen von Hintergrundaktivitäten installieren, z. B. Espresso Idling Resources. Außerdem können Sie Module durch Testversionen ersetzen, die Sie auf Inaktivität prüfen oder die die Synchronisierung verbessern können, z. B. TestDispatcher für Coroutinen oder RxIdler für RxJava.

Diagramm mit einem Testfehler, wenn die Synchronisierung auf dem Warten auf eine bestimmte Zeit basiert
Abbildung 2: Die Verwendung von „sleep“ in Tests führt zu langsamen oder instabilen Tests.

Stabilität verbessern

Bei großen Tests können viele Regressionen gleichzeitig erkannt werden, da mehrere Komponenten einer App getestet werden. Sie werden in der Regel auf Emulatoren oder Geräten ausgeführt, was eine hohe Wiedergabetreue bedeutet. 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 mit Compose oder Espresso umfangreiche Tests erstellen, starten Sie in der Regel eine Ihrer Aktivitäten und navigieren wie ein Nutzer. Mithilfe von Behauptungen oder Screenshot-Tests prüfen Sie, ob sich die Benutzeroberfläche richtig verhält.

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 jedoch eigene Konfigurationsrichtlinien gelten.

Von Gradle verwaltete Geräte

Wenn Sie Emulatoren selbst verwalten, können Sie mit von Gradle verwaltete Geräte festlegen, welche Geräte für die Ausführung 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"
        }
      }
    }
  }
}

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

./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 Inaktivitätsressourcen von Espresso verwenden, um anzugeben, wann eine App ausgelastet ist. Es ist jedoch 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 beeinträchtigen.

Anstatt zu schätzen, ob eine Aktivität ausgelastet ist oder nicht, können Sie Ihre Tests so konfigurieren, dass sie 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 eine Reihe von Test-APIs als Teil der ComposeTestRule, um auf verschiedene Matcher zu warten:

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.:

  • Zeitüberschreitung bei der Verbindung zum Gerät oder Verbindungsverlust
  • Einzelner Testfehler

Die Installation oder Konfiguration von Wiederholungen hängt von Ihren Test-Frameworks und Ihrer Infrastruktur ab. Zu den gängigen Mechanismen gehören:

  • Eine JUnit-Regel, mit der jeder Test mehrmals wiederholt wird
  • 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.