Hinweis:
Dieser Blogartikel ist älter als 5 Jahre – die genannten Inhalte sind eventuell überholt.
Obwohl sich das Espresso Framework schon seit längerer Zeit zum Standard für UI-Tests unter Android etabliert hat, ist die Hürde noch immer recht hoch diese auch zu implementieren. Durch den Espresso Test Recorder, der mit Android Studio 2.2 eingeführt wurde, hat sich diese Schwierigkeit deutlich verringert. Wir wollen uns also damit beschäftigen, wie wir relativ einfach UI-Tests generieren aber auch unabhängig von Remote-Servern machen können.
Disclaimer
Die Beispiel-Applikation soll zeigen, wie man relativ einfach mit dem Espresso Test Recorder sowie einer Mock-API Testfälle ausführen kann. Natürlich wird diese Beispiel-Applikation nicht alle Bereiche abdecken, mit denen wir uns tagtäglich im Android-Umfeld beschäftigen. Grundlegende Dinge wie RxJava, Dagger sowie das MVP Pattern werden hier nicht berücksichtigt, sollten aber in einer modernen Android App keinesfalls fehlen. Hierzu hat Kollege Daniel Ende 2016 bereits einen Vortrag auf dem GDG DevFest in Karlsruhe gehalten.
Espresso Test Recorder
Wir haben also eine App, die einen einfachen Login gegen eine Remote-API ausführen soll. Im Erfolgsfall werden wir eine neue Activity starten und auch hier Content über eine API anzeigen. Sollte der Login fehlschlagen, so öffnet sich ein Dialog, in dem ein entsprechender Fehlertext angezeigt wird.
Starten wir also den Espresso Test Recorder ( Run -> Record Espresso Test) und klicken uns etwas durch den Login-Bereich, so begrüßt uns beispielsweise dieser Stand.
Der Espresso Test Recorder führt also die App auf dem Emulator oder einem echten Gerät aus und protokolliert Aktionen wie z.B Eingaben oder Klicks. Zusätzlich hat man die Möglichkeit, anhand des aktuellen Bildschirminhalts Assertions auszuführen, um den Zustand der App zu prüfen.
Hat man sich bis zum gewünschten Zustand der App durchnavigiert, etwaige Assertions hinzugefügt und abgeschlossen, generiert Android Studio den passenden Code. Hierbei gilt es zu beachten, dass sich dieser generierte Code auf das verwendete Gerät bezieht. Ist also z.B ein Bereich, der geklickt werden soll, auf einem kleineren Gerät nicht sichtbar, schlägt der Test auf diesem Gerät fehl. Ein häufiger Fehlerfall — dazu später mehr.
Wir werden am Ende 2 Testfälle haben. Zum einen wollen wir prüfen, ob ein fehlgeschlagener Login einen entsprechenden Dialog anzeigt, zum anderen, ob ein erfolgreicher Login uns zu unserer MainActivity führt, in der von der Mock-API bereitgestellte Daten korrekt angezeigt werden. Bevor man die Mock-API einführt, empfiehlt sich der Einfachheit halber, diese Tests mit einem echten Environment zu generieren und sie anschließend auf die Mock-API umzustellen.
Lets have some code…
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
@Test public void login_fail_should_display_error_dialog() throws Exception { ViewInteraction emailEditText = onView(withId(R.id.edittext_email)); emailEditText.perform(scrollTo(), replaceText("as@asd.de"), closeSoftKeyboard()); ViewInteraction pwEditText = onView(withId(R.id.edittext_password)); pwEditText.perform(scrollTo(), replaceText("12345"), closeSoftKeyboard()); ViewInteraction signInButton = onView(withId(R.id.button_sign_in)); signInButton.perform(scrollTo(), click()); ViewInteraction dialogTitle = onView(allOf(withId(R.id.alertTitle), isDisplayed())); dialogTitle.check(matches(withText(R.string.dialog_title_error))); ViewInteraction dialogText = onView(allOf(withId(android.R.id.message), isDisplayed())); dialogText.check(matches(withText(R.string.dialog_message_login_failed))); } |
Wie man sieht, haben wir den vom TestRecorder generierten Code etwas abgespeckt und unnötigen Code entfernt. Was wir nun haben, ist ein simpler Testfall, der auf unserer LoginActivity die Felder E-Mail und Passwort füllt, anschließend auf den Sign In Button klickt und prüft, ob ein Dialog mit entsprechenden Texten (Title & Message) dargestellt wird.
MockWebServer
Wir können diesen Test nun umstellen, um gegen die oben angekündigte Mock-API zu testen. Hierfür werden wir den von OkHttp mitgelieferten MockWebServer nutzen. Mithilfe dieses MockWebServers können wir innerhalb der Tests vordefinierte Responses queuen und damit verschiedenes Verhalten der App testen.
ProductFlavor
Um zu definieren, welche Umgebung/API-URL wir für die Tests nutzen wollen, gibt es verschiedene Ansätze. Je nach Projekt-Setup können wir per Dagger ein entsprechendes Environment injecten, vor dem Test unsere Host URL entsprechend anpassen, oder einen neuen Gradle Build Flavor hinzufügen. Wir haben uns hier für die einfache Variante des Build Flavors entschieden:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
productFlavors { app { } ui_tests { buildConfigField 'String', 'HOST', '"http://127.0.0.1:43210/api/"' } } |
Wir überschreiben hierbei das buildConfigField, das wir in unserer defaultConfig definiert haben, mit einer lokalen URL auf Port 43210. Da wir in unserem Retrofit-Setup die Base URL per BuildConfig setzen, wird diese durch das Ausführen der Tests mit dem ausgewählten Build Flavor entsprechend ersetzt.
Anpassung des Tests
Nun passen wir unseren zuvor angelegten Test so an, dass die Mock-API entsprechend unseren Bedürfnisse „gefüllt“ ist.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
private static MockWebServer server; @BeforeClass public static void startMockServer() throws Exception { server = new MockWebServer(); server.start(43210); } @AfterClass public static void shutdownMockServer() throws Exception { server.shutdown(); } @Test public void login_fail_should_display_error_dialog() throws Exception { MockResponse loginResponse = new MockResponse().setResponseCode(403); server.enqueue(loginResponse); //Der Rest des Codes bleibt unverändert.. } |
Wir legen also fest, dass zunächst der MockWebServer mit entsprechendem Port (wie in unserem Flavor definiert) gestartet wird, bevor ein Test in dieser Testklasse ausgeführt wird. Innerhalb des Tests queuen wir dann eine MockResponse mit dem Response Code 403.
Führen wir unseren Test nun erneut aus, so verwendet Retrofit für alle API Requests unseren MockWebServer – den wir mit dem oben stehenden Code vorab gefüllt haben.
Yet another Test
Nun fehlt noch der versprochene zweite Test, der einen erfolgreichen Login ausführen wird, um anschließend auf der MainActivity zu prüfen, ob die korrekten Daten angezeigt werden.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
@Test public void login_success_should_lead_to_mainactivity_and_display_correct_string() throws Exception { MockResponse loginResponse = new MockResponse().setResponseCode(200); MockResponse someresponse = new MockResponse().setResponseCode(200) .setBody("{\"isAndroidTestingFunny\": true}"); server.enqueue(loginResponse); server.enqueue(someresponse); ViewInteraction emailEditTxt = onView(withId(R.id.edittext_email)); emailEditTxt.perform(scrollTo(), replaceText("as@asd.de"), closeSoftKeyboard()); ViewInteraction pwEditText = onView(withId(R.id.edittext_password)); pwEditText.perform(scrollTo(), replaceText("12345"), closeSoftKeyboard()); ViewInteraction signInButton = onView(withId(R.id.button_sign_in)); signInButton.perform(scrollTo(), click()); ViewInteraction textViewWithTextFromApi = onView(allOf(withId(R.id.textView), isDisplayed())); textViewWithTextFromApi.check(matches(withText("Android testing is funny: true"))); } |
Hier haben wir 2 Responses zu unserer Mock-API Queue hinzugefügt: Die erste Response impliziert einen korrekten Login, während die zweite erfolgreiche Response einen Body hat, den wir hier im Code definiert haben. Bei größeren Responses bietet sich die Möglichkeit an, json-Dateien im Ordner app/src/androidTest/assets abzulegen und auszulesen. (Siehe Beispielprojekt)
Die hinzugefügten Requests werden sequentiell hinzugefügt, abgerufen und dadurch entfernt.
Es sollte immer die genaue Anzahl der benötigten Requests hinzugefügt werden, da bei zu wenigen Requests die App nicht wie erwartet funktionieren wird. Bei zu vielen hinzugefügten Requests werden aller Voraussicht nach etwaige Folgetests scheitern, da diese auf einer Queue aufsetzen, die nicht korrekt für den jeweiligen Test vorbereitet ist.
Indem wir unsere Tests auf eine lokale Mock-Umgebung umstellen ist gewährleistet, dass unsere Tests auch lokal ohne Zugang zum Remote Server funktionieren.
Tipps
scrollTo()/NestedScrollView
Wie oben bereits erwähnt, sind die generierten Tests immer auf das ausführende Gerät zugeschnitten. Es ist also nicht unüblich, dass sie gerade auf Geräten mit verschiedenen Displaygrößen zum Teil scheitern.
Es empfiehlt sich also, auf verwendeten UI Elementen zusätzlich zum Input/Click noch ein scrollTo() ausführen zu lassen, um sicher zu stellen, dass wir unsere zu verwendende View auch sehen. scrollTo() kann auch verwendet werden, wenn wir wie in unserem Beispiel überhaupt keine ScrollView verwenden.
button.perform(scrollTo(), click());
scrollTo() hat leider das Problem, dass es nicht korrekt bei der Verwendung von NestedScrollingViews funktioniert. Dies ist ein bekannter Fehler – Abhilfe schafft folgendes Snippet. Damit ist es nun auch möglich, zu View-Elementen zu scrollen, die sich innerhalb einer NestedScrollingView befinden.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 |
/** * Improved {@link android.support.test.espresso.action.ScrollToAction} which works with * {@link NestedScrollView} */ public final class NestedScrollToAction implements ViewAction { @SuppressWarnings("unchecked") @Override public Matcher<View> getConstraints() { return allOf(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE), isDescendantOfA(anyOf( isAssignableFrom(ScrollView.class), isAssignableFrom(HorizontalScrollView.class), isAssignableFrom(NestedScrollView.class)))); } @Override public void perform(UiController uiController, View view) { if (isDisplayingAtLeast(90).matches(view)) { return; } Rect rect = new Rect(); view.getDrawingRect(rect); if (!view.requestRectangleOnScreen(rect, true /* immediate */)) { Log.w("nestedScrollAction", "Scrolling to view was requested, but none of the parents scrolled."); } uiController.loopMainThreadUntilIdle(); if (!isDisplayingAtLeast(90).matches(view)) { throw new PerformException.Builder() .withActionDescription(this.getDescription()) .withViewDescription(HumanReadables.describe(view)) .withCause(new RuntimeException( "Scrolling to view was attempted, but the view is not displayed")) .build(); } } @Override public String getDescription() { return "scroll to"; } } //Convenient Methode, welche statt scrollTo() verwendet werden kann public static ViewAction betterScrollTo() { return ViewActions.actionWithAssertions(new NestedScrollToAction()); } |
Checkboxen/Radio Buttons
Auch die Verwendung von Checkboxen/Radio Buttons innerhalb von Tests ist nicht trivial. Android Studio generiert beim Testen von Checkboxen/Radio Buttons Code, der so leider nicht funktioniert. Der Code führt zwar ein click() aus, die View ist dadurch allerdings nicht checked. Aber auch hier gibt es Abhilfe:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
public static ViewAction setChecked(final boolean checked) { return new ViewAction() { @Override public BaseMatcher<View> getConstraints() { return new BaseMatcher<View>() { @Override public boolean matches(Object item) { return isA(Checkable.class).matches(item); } @Override public void describeMismatch(Object item, Description mismatchDescription) {} @Override public void describeTo(Description description) {} }; } @Override public String getDescription() { return null; } @Override public void perform(UiController uiController, View view) { Checkable checkableView = (Checkable) view; checkableView.setChecked(checked); } }; } |
Den gesamten Quellcode sowie weitere Funktionen die hier und da Abhilfe zu bekannten Problemen schaffen können, mit entsprechender Beispiel Applikation findet ihr auf unserem Github Profil.
Firebase
Zu guter Letzt soll auch das von Google entwickelte Firebase Testing Lab kurz Erwähnung finden. Dieses ermöglicht es, eure Tests auf verschiedenen (echten) Geräten auszuführen. Zur Verfügung stehen die meisten Flagship-Geräte mit z.T verschiedenen API Leveln. Das kostenlose Kontingent an Ausführungen ist relativ beschränkt – man kann Tests nur 1x am Tag auf 5 echten Geräten ausführen — aber dennoch eine gute Sache, um das Angebot zu testen.
Verstärkung gesucht!
Ihr seid selbst begeisterte Android-Entwickler und auf der Suche nach neuen Herausforderungen? Wir stellen in Karlsruhe, Pforzheim, Stuttgart, München, Köln und Hamburg derzeit besonders Android-Embedded-Entwickler ein, bieten euch aber auch als PraktikantInnen oder WerkstudentInnen Einblicke in das Entwicklerleben. Ihr wollt eure Abschlussarbeit im Bereich Software-Entwicklung schreiben? Auch das könnt ihr bei uns! Und wenn euch besonders der Feinschliff von Software am Herzen liegt, bewerbt euch als Digital Quality Advocat!