+49 (0) 721-78 80 27 30

Einführung in User Interface (UI) Tests mit Espresso

Das Sicherstellen von Softwarequalität mithilfe von Tests ist ein wichtiges Thema in der Softwareentwicklung. Hierdurch können frühzeitig Fehler festgestellt und behoben werden. Auch Fehler, die im weiteren Verlauf der Programmierung entstehen, werden so ersichtlich.

In Android unterscheidet man grundlegend zwischen zwei Arten von Tests: Den Unit Tests und den Instrumentation Tests. Im nachfolgenden Beitrag sollen User Interface Tests (UI-Tests), eine Art der Instrumentation Tests, näher betrachtet werden. Hervorzuheben ist an dieser Stelle jedoch, dass nur durch den Einsatz aller Testarten die Qualitätssicherung des Codes optimal gewährleistet ist.

UI-Tests

In der klassischen Programmierung wird die Codebase hauptsächlich mit Unit- und Integration-Tests abgedeckt. Das sollte man auch bei der Programmierung für Android so handhaben. In diesem Beitrag wird jedoch das Sicherstellen eines fehlerfreien User Interfaces (UI) fokussiert.

Bei Android kann man UI-Tests nativ mithilfe des Frameworks Espresso abbilden. Daneben sind aber auch andere Test Frameworks wie Robotium zu nennen, mit denen ebenfalls die Erstellung von UI-Tests für Android möglich ist.

Espresso

Espresso ist ein Framework von Google, welches speziell für das Testen von Android User Interfaces entwickelt wurde. Es wurde im April 2013 bei der Google Test Automation Conference vorgestellt und im Oktober des selben Jahres für Entwickler zugänglich gemacht. Espresso ermöglicht es, native UI-Tests zu schreiben und so direkt auf Code der Entwicklung zuzugreifen.

Setup

Da es sich bei Espresso um ein natives Framework handelt, ist die Einrichtung vergleichsweise einfach. Im ersten Schritt müssen zunächst die neuesten Versionen der folgenden Dependencies zur build.gradle der Applikation hinzugefügt werden:

androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
androidTestImplementation 'com.android.support.test:runner:1.0.2'
androidTestImplementation 'com.android.support.test:rules:1.0.2'

Außerdem muss der Testrunner in der defaultConfig Sektion der build.gradle ergänzt werden:

testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"

Anschließend ist das Setup auch schon abgeschlossen und es kann mit dem Schreiben der Tests begonnen werden.

Tests mit Espresso

In diesem Beispiel soll eine kleine Application getestet werden, die aus zwei Screens besteht. Der erste Screen enthält zwei Buttons, und eine TextView, der zweite Screen enthält eine RecyclerView und einen FloatingActionButton (FAB). Über der Liste soll eine TextView stehen.
Für die Umsetzung der Screens wurden zwei Fragments in einer Activity verwendet.
 Die Liste wurde mithilfe einer RecyclerView erstellt.

Klickt man im ersten Screen auf den linken Button, so wird der Counter in der TextView hochgezählt.

espresso3

Ein Klick auf den rechten Button öffnet den zweiten Screen.

espresso1

Im zweiten Screen soll bei Klick auf den FAB ein Element zur Liste hinzugefügt werden. Ein Element der Liste besteht aus einer TextView und einer Checkbox.

In der TextView soll das Element mit Nummer stehen also “Item 0” beim ersten Element, “Item 1” beim zweiten Element usw. Außerdem soll in der TextView über der Liste die Anzahl der Elemente, die ausgewählt sind und die Anzahl der gesamten Elemente angezeigt werden. Das soll wie folgt dargestellt werden: “2/5 elements selected”. Nach Klick auf den „Back“ Button oben links kehrt die Ansicht zum ersten Screen zurück.

espresso2

Für diese Applikation werden nun UI-Tests definiert. Der erste Schritt hierfür ist das Anlegen einer Rule für die verwendete Activity.

public class MainActivityTestRule extends ActivityTestRule{

    public MainActivityTestRule(Class activityClass) {

        super(activityClass);
    }
}

Es kann hier auch die Standard ActivityTestRule verwendet werden. Da aber später an dieser Stelle noch etwas überschrieben wird, soll eine eigene Rule für die MainActivity angelegt werden.

Im Anschluss kann nun der erste Espresso Test geschrieben werden. Hierfür legt man eine Klasse im “androidTest” Ordner an, in welchem standardmäßig nach Integration Tests gesucht wird. Da keine extrem große Testklasse benötigt wird, kann eine Klasse für den ersten Screen und eine für den zweiten Screen erstellt werden. Damit die Rule in beiden verwendet werden kann, legt man die “BaseTest“ Klasse an, von der die beiden Testklassen erben können. Anschließend kann eine neue Instanz der Rule angelegt werden:

@Rule
public MainActivityTestRule mActivityRule = new MainActivityTestRule<>(MainActivity.class);

Im nächsten Schritt kann eine Testklasse für den ersten Screen angelegt werden. Diese erbt von „BaseTest“. Mit der Annotation @Test wird nun eine Methode markiert, die einen Test repräsentiert. Es empfiehlt sich, die Testmethoden aussagekräftig zu benennen, damit direkt ersichtlich ist, was genau getestet wird. Bei UI-Tests empfiehlt sich folgende Notation:

test_wasSollImTestPassieren_WasWirdErwartet()

Dieses Vorgehen kann dazu führen, dass die Namen der Testmethoden sehr lange werden, aber erlaubt es dem Entwickler auch ohne Dokumentation schnell einzusehen, was in dieser Methode getestet wird.

Im ersten Test soll überprüft werden, ob die UI mit den richtigen Werten initialisiert wurde:

  • Im Textfeld wird der korrekte Inhalt angezeigt
  • Beide Buttons haben das passende Label
@Test
public void test_uiHasSetupCorrectly_AllViewsShouldBeVisibleAndHaveCorrectValues(){
    onView(withId(R.id.fragment_button_counter_text_view)).check(matches(withText("Number of Button Clicks: 0")));
    onView(withId(R.id.fragment_button_counter_button)).check(matches(withText("Count")));
    onView(withId(R.id.fragment_button_fragment_change_button)).check(matches(withText("Change Fragment")));
}

Mit onView() wird die View selektiert, die getestet werden soll. Die id ist die “android:id”, die im XML Layout File für ein UI-Element festgelegt wird. Anschließend wird über check() ein Matcher erwartet. In unserem Fall wird jeweils nur der String verglichen.
Hier sollten besser String Resources anstelle von festen Strings verwendet werden. Dies hat zwei Vorteile:

  • Eine Änderung betrifft sofort den Test und die Applikation
  • Sollte die Applikation übersetzt werden kann man die Tests unabhängig von der Gerätesprache ausführen

Für die Testapplikation empfiehlt es sich aber, bei festen Strings zu bleiben.

Nun ist sichergestellt, dass der Screen ordentlich aufgebaut wurde. Die nächste Funktion des ersten Screens ist das Klicken auf den linken Button und das Hochzählen des Counters im Textfeld. Neben der Überprüfung eines Elementes wie im vorangegangen Test, kommt hier außerdem das Klicken des Buttons, also eine Action hinzu:

@Test
public test_ButtonClickUpdatesCounter_CounterCountsUp(){

onView(withId(R.id.first_screen_text_view)).check(
        matches(withText(“0 Klicks”)));
onView(withId(R.id.first_screen_left_button)).perform(click());
onView(withId(R.id.first_screen_text_view)).check(
        matches(withText(“1 Klicks”)));
}

Zunächst wird geprüft, ob der Anfangswert korrekt ist, anschließend wird über die onView(withId()) der linke Button ausgewählt und über perform() eine Action ausgewählt, die auf dem Objekt ausgeführt werden soll. In unserem Fall ist die Action click().

Anschließend sollte kontrolliert werden, ob der Counter hochgezählt wurde. Auf eine Unterscheidung zwischen Singular und Plural bei einem oder mehreren Elementen wurde hier verzichtet. Um sicherzustellen, dass dies auch mehr als einmal funktioniert, wird ein weiterer Test hinzugefügt, in welchem der Button zehn Mal geklickt wird:

@Test
public test_ButtonClickUpdatesCounter_CounterCountsUp(){

onView(withId(R.id.first_screen_text_view)).check(
        matches(withText(“0 Klicks”)));
for(int i = 0; i

Die click() Action wird nun in einer Schleife zehn mal hintereinander ausgeführt. Im Anschluss erfolgt die Überprüfung, ob korrekt mitgezählt wurde.

Es gibt im ersten Screen noch eine zweite Action, deren korrekte Ausführung mittels eines UI-Tests sichergestellt werden soll. Durch Klick auf den rechten Button soll sich der zweite Screen öffnen.

Um zu testen, ob sich die View tatsächlich geöffnet hat, kann geprüft werden, ob ein Layout Element des zweiten Screens sichtbar ist. Man könnte alternativ auch den Titel der Toolbar oder ein anderes Alleinstellungsmerkmal der View verwenden. In unserem Fall wurde der FAB im zweiten Screen ausgewählt:

@Test
public test_ButtonClickUpdatesCounter_CounterCountsUp(){

onView(withId(R.id.first_screen_right_button)).perform(click());

onView(withId(R.id.second_screen_floating_action_button)).check(matches(isDisplayed()));
}

Nach Klick auf den Button im ersten Screen kann der FAB über die Layout-ID gefunden werden und es wird überprüft, ob der Button angezeigt wird.
Nun sind alle Funktionen des ersten Screens abgedeckt. Die Tests für den zweiten Screen werden in der zweiten Testklasse angelegt. Hierbei fällt auf, dass alle Tests zunächst zum zweiten Screen navigieren müssen, bevor sie ausgeführt werden können.
Da dies für alle Tests in dieser Testklasse gilt, kann hier die @Before Annotation verwendet und vor jedem Test ausgeführt werden. Andernfalls müsste in jedem Test eine Methode aufgerufen werden, die zum zweiten Screen navigiert.

@Before
public void setUp(){
	onView(withId(R.id.first_screen_right_button)).perform(click());	
}

Nun kann mit der Prüfung des zweiten Screens begonnen werden, folgendes sollte getestet werden:

  • Die UI ist im Grundzustand korrekt aufgebaut worden.

Ein Klick auf den FAB fügt ein Element zur Liste hinzu, die TextView in dem hinzugefügten Element enthält den korrekten Text und die TextView über der Liste hat sich korrekt aktualisiert.

  • Das Anwählen einer Checkbox aktualisiert die TextView über der Liste.
  • Das Anwählen einer Checkbox und anschließende Hinzufügen eines Items lässt die Checkbox angewählt und die TextView über der Liste wird korrekt aktualisiert.
  • Das Abwählen einer Checkbox und anschließende Hinzufügen eines Items lässt die Checkbox abgewählt und die TextView über der Liste wird korrekt aktualisiert.

Um die UI im Grundzustand zu überprüfen, muss hier nur der Inhalt der TextView über der Liste und die Sichtbarkeit des FAB überprüft werden.

Nun zum ersten Test, der ein Element einer RecyclerView enthält. Der Klick auf den FAB kann analog zu den Klicks auf die Buttons im ersten Screen realisiert werden. Um ein Element einer RecyclerView zu untersuchen, benötigt man einen eigenen ViewMatcher. Durch eigene ViewMatcher kann man in Espresso nahezu jedes Element untersuchen. Oft gibt es bereits Implementierungen von ViewMatchern für UI Elemente. Für die RecyclerView wurde ViewMatcher verwendet.

Anschließend wird in der „BaseTest“ Klasse eine Methode withRecyclerView() eingeführt, um den Watcher wie die Standard Espresso Matcher aufrufen zu können.

public static RecyclerViewMatcher withRecyclerView(final int recyclerViewId) {

    return new RecyclerViewMatcher(recyclerViewId);
}

Nun können die Elemente der RecyclerView getestet werden. Hierbei sollte man sich angewöhnen, vor dem Testen eines Elements zu dem Index des Elements zu scrollen. Dies ist notwendig, da Espresso nur ein Element testen kann, das auf dem Bildschirm angezeigt wird ist. Um das Scrollen der RecyclerView möglich zu machen, müssen wir nun auch das “espresso-contrib” Repository einbinden. Dieses enthält zusätzliche Möglichkeiten UI-Elemente, zu matchen oder mit ihnen zu interagieren. Wir benötigen für unseren Test diese RecyclerViewActions:

@Test
public void test_clickFAB_itemIsAddedToListTextViewUpdated() {

    onView(withId(R.id.list_fragment_floating_action_button)).perform(click());
    onView(withId(R.id.list_fragment_recycler_view)).perform(RecyclerViewActions.scrollToPosition(0));
    onView(withRecyclerView(R.id.list_fragment_recycler_view).atPosition(0)).check(
            matches(hasDescendant(withText("Item 0"))));
    onView(withId(R.id.list_fragment_title_text_view)).check(matches(withText("0/1 items selected")));
}

Nach dem Klick auf den FAB scrollt man mithilfe der RecyclerViewAction scrollToPosition(index) zum Index der Liste, der überprüft werden soll. Dies ist hier zwar nicht unbedingt notwendig, da das erste Element der List im Normalfall immer sichtbar ist, aber so wird der Schritt auch in anderen Fällen nicht vergessen. Zusätzlich sollte man bedenken, dass das Gerät, mit welchem man entwickelt, nicht zwingend das gleiche Gerät ist, auf dem die Tests ausgeführt werden müssen.

Anschließend kann mithilfe des Matchers auf das Listen Element an der Stelle 0 zugegriffen und überprüft werden, ob eine ihrer Subviews den korrekten Text enthält. Da hier nur ein Element mit dem entsprechenden Text vorgesehen ist, reicht das aus.

Als nächstes soll eine Checkbox angewählt und im Anschluss überprüft werden, ob der Text über der Liste sich korrekt ändert:

@Test
public void test_addElementSelectElementCheckBox_shouldRefreshTextViewCorrectly() {

    onView(withId(R.id.list_fragment_floating_action_button)).perform(click());
    onView(withId(R.id.list_fragment_recycler_view)).perform(RecyclerViewActions.scrollToPosition(0));
    onView(withId(R.id.list_fragment_recycler_view)).perform(
            RecyclerViewActions.actionOnItemAtPosition(0, clickChildViewWithId(R.id.row_list_item_checkbox)));
    onView(withId(R.id.list_fragment_title_text_view)).check(matches(withText("1/1 items selected")));
}

Hier kommt als Neuerung der Klick auf die Checkbox hinzu. Da sich die Checkbox jeweils in einem Listenelement befindet, kann man hier wieder eine RecyclerViewAction verwenden. Mit actionOnItemAtPosition kann ein Index und eine ViewAction mitgegeben werden:

public class ChildClickViewAction {

    public static ViewAction clickChildViewWithId(final int resourceId) {
        return new ViewAction() {
            @Override
            public Matcher getConstraints() {
                return null;
            }

            @Override
            public String getDescription() {
                return "Click on a child view with specified id.";
            }

            @Override
            public void perform(UiController uiController, View view) {
                View v = view.findViewById(resourceId);
                v.performClick();
            }
        };
    }
}

In diesem Fall wird über die ResourceId das entsprechende Element innerhalb der gegebenen View gesucht und auf diese View geklickt. Nach dem Klick auf die Checkbox wird überprüft, ob sich die TextView über der Liste auch entsprechend aktualisiert hat.

Für die letzten Tests werden keine neuen Konzepte mehr benötigt, daher werden sie an dieser Stelle nicht explizit aufgelistet. Beide Tests sind jedoch über Github einsehbar.

Abschließend soll noch geprüft werden, ob die Navigation über den „Zurück“-Button oben links funktioniert. Hierbei ist auf eine Besonderheit zu achten: Leider kann Espresso nicht mit der Id auf den Button zugreifen. Zumindest dann nicht, wenn dieser mit setHomeAsUpEnabled(true) erstellt wurde. Stattdessen wird hierfür eine Description verwendet:

@Test
public void test_pressBackButton_shouldReturnToButtonFragment(){

    onView(withContentDescription(R.string.abc_action_bar_up_description)).perform(click());
    onView(withId(R.id.fragment_button_counter_text_view)).check(matches(isDisplayed()));
}

Um also auf den „Zurück“-Button zu klicken, muss zunächst die Description ermittelt werden. Diese hat eine feste Resource Id, d.h. dieser Test funktioniert auch sprachunabhängig. Im letzten Schritt wird geprüft, ob die TextView im ersten Screen angezeigt wird.

Nun wurde die korrekte Funktionsweise der App fast vollständig mit UI-Tests sichergestellt. Ein spezieller Fall sollte jedoch noch berücksichtigt werden. Bisher würden die Tests nur laufen, wenn das Testgerät entsperrt ist. Bei ausgeschaltetem Bildschirm oder gesperrten Gerät schlagen alle Tests fehl, da die Activity nicht gefunden wird. Daher soll das Gerät entsperrt und den Bildschirm angeschaltet werden, bevor die Tests ausgeführt werden. Hierfür darf das Testgerät keine Sperre wie eine PIN besitzen.

Hierfür müssen wir wider in unser MainActivityTestRule Klasse zurück und das Interface ActivityLifecycleCallback implementieren.
Dies ermöglicht es uns die Methode onActivityLifecycleChanged() zu implementieren.

@Override
public void onActivityLifecycleChanged(Activity activity, Stage stage) {

    if (stage == Stage.PRE_ON_CREATE) {
        activity.getWindow()
                .addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
                        | WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
                        | WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON);
    }
}

Hier prüft man, ob sich die Activity im PRE_ON_CREATE Status, also bevor sie erstellt wird, befindet und versucht, den Bildschirm anzuschalten und das Gerät zu entsperren.

Zusätzlich muss die Methode beforeActivityLaunched überschrieben werden, um den Listener zu setzen. So werden die Tests auch bei ausgeschaltetem Bildschirm ausgeführt.

@Override
protected void beforeActivityLaunched() {

    super.beforeActivityLaunched();

    ActivityLifecycleMonitorRegistry.getInstance().addLifecycleCallback(this);
}

Abschließend lässt sich festhalten, dass Espresso ein wertvolles Tool bei der App-Entwicklung für Android ist, dessen Nutzung im Zusammenspiel mit anderen Testarten sehr zu empfehlen ist.

Die gesamte Applikation inklusive aller hier beschriebenen Tests steht auf GitHub zur Verfügung.

Referenzen:

[1]: https://developers.google.com/google-test-automation-conference/2013/presentations
[2]: https://testing.googleblog.com/2013/10/espresso-for-android-is-here.html

This entry was posted in Technical. Bookmark the permalink.

Comments are closed.