Do Androids Dream of Electric Sheep?

Robolectric: Unit Tests für Android-Apps
Kommentare

Testen ist wichtig, darüber herrscht Einigkeit. Regelmäßiges, am besten kontinuierliches Testen führt zu höherer Qualität der Software, frühzeitiger Aufdeckung von Unstimmigkeiten in den Anforderungen und mehr Sicherheit beim Refactoring. Unit Tests sollten mittlerweile zum Standardhandwerkszeug eines jeden Softwareentwicklers gehören.

Eine der Kernanforderungen für Unit Tests ist, dass sie schnell ausgeführt werden können – je schneller, desto besser. Je weniger Zeit die Ausführung der Unit Tests für ein Softwaremodul in Anspruch nehmen, desto mehr sind Entwickler bereit, die Tests vor und nach jeder Änderung des Codes auszuführen. Idealerweise sollte eine Testsuite in weniger als zehn Sekunden ausgeführt werden können. Grund hierfür: Die menschliche Aufmerksamkeitsspanne liegt bei etwa 8 bis 10 Sekunden. Bei testgetriebenem Vorgehen sieht der übliche Entwicklungszyklus wie folgt aus: Test schreiben, Test ausführen (Rot), Code schreiben, Test ausführen (Grün), Refactoring ausführen, Test erneut ausführen (hoffentlich immer noch Grün). Während der Test ausgeführt wird, ist man zum Warten verdammt. Ist die Wartezeit zu lang, schweift man ab.

Genau diese Anforderung stellt im Android-Umfeld eine große Herausforderung dar. Unit Tests für Android-Apps müssen entweder auf dem Emulator oder einem physikalischen Gerät ausgeführt werden. Grund hierfür ist, dass die Android Runtime, die im SDK als android.jar zur Verfügung steht, nur leere Methodenrümpfe enthält. Ein Versuch, diese Methoden außerhalb des Emulators oder eines Android-Geräts aufzurufen, führt zu einer RuntimeException mit dem Hinweis darauf, dass die entsprechende Methode ausgestubbt ist. Nun könnte man versucht sein, die entsprechenden Klassen mithilfe von Mocks nachzubilden. Einerseits ist dies jedoch ziemlich aufwändig und führt andererseits dazu, dass die Mocks zu einer spiegelbildlichen Implementierung der getesteten Klasse degenerieren. Zusätzlich muss der Java-Code vor der Ausführung auf dem Emulator oder einem Android-Gerät noch in das DEX-Format konvertiert, in ein APK gepackt und auf das Zielgerät hochgeladen werden. Alle diese Schritte führen zu einem nicht zu vernachlässigenden Overhead.

Robolectric sorgt dafür, dass Unit Tests in einer regulären JVM ausgeführt werden können (Kasten: „Projektspezifikation“). Somit entfallen alle für das Deployment auf ein Gerät notwendigen Schritte, die Turnaround-Zeiten werden kürzer und es macht wieder Spaß, Tests auszuführen.

Installation

Um Robolectric zu nutzen, muss es in das zu testende Projekt integriert werden. Die Robolectric-Dokumentation beschreibt die Integration sowohl für Eclipse-Projekte als auch für die Verwendung von Android Studio, siehe hier. Für diesen Artikel verwenden wir die Gradle-basierte Integration von Robolectric. Um Robolectric in ein neues Android-Projekt zu integrieren, ist wie folgt vorzugehen:

  1. Projekt gemäß der Angaben im Kasten „Projektspezifikation“ anlegen
  2. Robolectric-Gradle-Plug-in in das Build-Skript des Hauptprojekts integrieren (Listing 1)
  3. Build-Skript für das App-Projekt konfigurieren (Listing 2)
  4. Nicht vergessen: Gradle synchronisieren (Tools | Android | Sync Project with Gradle Files)

Insbesondere Schritt 3 ist essenziell: Hier werden Robolectric und Hamcrest als Abhängigkeiten für die Testphase angegeben sowie dafür gesorgt, dass beim Kompilieren doppelt vorkommende Dateien ignoriert werden.

Projektspezifikation

Create New Project

  • Application Name: RobolectricDemoJavaMagazin
  • Domain: javamagazin.de
  • (Next)
  • [x] Phone and Tablet
  • API Level: 15 (ICS)
  • (Next)
  • [x] Blank Activity
  • (Next)
  • Activity Name: MyActivity
  • Layout Name: activity_my
  • Title: MyActivity

Listing 1: (/build.gradle)
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
jcenter()
mavenCentral()
}

dependencies {
classpath ‚com.android.tools.build:gradle:0.12.2‘
classpath ‚org.robolectric:robolectric-gradle-plugin:0.12.+‘
}
}

allprojects {
repositories {
jcenter()
}
}

Listing 2: (/app/build.gradle)
apply plugin: 'com.android.application'
apply plugin: 'robolectric'

android {
compileSdkVersion 19
buildToolsVersion „19.1.0“

defaultConfig {
applicationId „de.javamagazin.robolectricdemojavamagazin“
minSdkVersion 15
targetSdkVersion 18
versionCode 1
versionName „1.0“
}

buildTypes {
release {
runProguard false
proguardFiles getDefaultProguardFile(‚proguard-android.txt‘), ‚proguard-rules.pro‘
}
}

// avoid duplicates
packagingOptions {
exclude ‚LICENSE.txt‘
exclude ‚META-INF/LICENSE‘
exclude ‚META-INF/LICENSE.txt‘
exclude ‚META-INF/NOTICE‘
}
}

robolectric {
include ‚**/*Test.class‘
}

dependencies {
compile fileTree(dir: ‚libs‘, include: [‚*.jar‘])
compile ‚com.android.support:support-v4:20.+‘ // <– required?

androidTestCompile ‚org.hamcrest:hamcrest-integration:1.1‘
androidTestCompile ‚org.hamcrest:hamcrest-core:1.1‘
androidTestCompile ‚org.hamcrest:hamcrest-library:1.1‘

androidTestCompile(‚junit:junit:4.+‘) {
exclude module: ‚hamcrest-core‘
}
androidTestCompile(‚org.robolectric:robolectric:2.+‘) {
exclude module: ‚classworlds‘
exclude module: ‚commons-logging‘
exclude module: ‚httpclient‘
exclude module: ‚maven-artifact‘
exclude module: ‚maven-artifact-manager‘
exclude module: ‚maven-error-diagnostics‘
exclude module: ‚maven-model‘
exclude module: ‚maven-project‘
exclude module: ‚maven-settings‘
exclude module: ‚plexus-container-default‘
exclude module: ‚plexus-interpolation‘
exclude module: ‚plexus-utils‘
exclude module: ’support-v4′
exclude module: ‚wagon-file‘
exclude module: ‚wagon-http-lightweight‘
exclude module: ‚wagon-provider-api‘
}
}

Tests schreiben

Anhand eines einfachen Beispiels soll nun demonstriert werden, wie Robolectric die testgetriebene Entwicklung einer App ermöglicht. Um das Beispiel überschaubar zu halten, werden wir eine extrem einfach gehaltene App im „Hello World“-Stil entwickeln. Der Benutzer soll in einem Eingabeformular seinen Namen eingeben und wird dann auf Knopfdruck begrüßt.

Zunächst bauen wir das UI für unsere App auf. Dazu gestalten wir es wie in Abbildung 1 dargestellt, indem wir einfach ein Eingabefeld (EditText), einen Button und einen Schriftzug (TextView) einfügen. Listing 3 zeigt den entsprechenden Quellcode des Layouts.

Abb. 1: UI der Demo-App

Abb. 1: UI der Demo-App

Listing 3: (activity_my.xml)
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
               xmlns:tools="http://schemas.android.com/tools"
               android:layout_width="match_parent"
               android:layout_height="match_parent"
               android:paddingLeft="@dimen/activity_horizontal_margin"
               android:paddingRight="@dimen/activity_horizontal_margin"
               android:paddingTop="@dimen/activity_vertical_margin"
               android:paddingBottom="@dimen/activity_vertical_margin"
               tools:context=".MainActivity$PlaceholderFragment">

<TextView
   android:text=“@string/hello_world“
   android:layout_width=“wrap_content“
   android:layout_height=“wrap_content“
   android:textAppearance=“?android:attr/textAppearanceLarge“
   android:id=“@+id/textView“
   android:layout_alignRight=“@+id/editText“
   android:layout_alignParentLeft=“true“/>

<EditText
   android:layout_width=“wrap_content“
   android:layout_height=“wrap_content“
   android:id=“@+id/editText“
   android:layout_below=“@+id/textView“
   android:layout_marginTop=“24dp“
   android:layout_alignParentRight=“true“
   android:layout_alignParentLeft=“true“/>

<Button
   android:layout_width=“wrap_content“
   android:layout_height=“wrap_content“
   android:text=“Say Hi!“
   android:id=“@+id/button“
   android:layout_below=“@+id/editText“
   android:layout_centerHorizontal=“true“
   android:layout_marginTop=“45dp“/>

</RelativeLayout>

Als Nächstes muss die Klasse ApplicationTest gelöscht werden – sie führt später sonst zu unerwünschten Fehlermeldungen bei der Ausführung der Tests.

Die Tests für unsere Activity bringen wir in der Klasse MyActivityTest unter. Damit diese Klasse mit dem richtigen Test Runner ausgeführt wird, geben wir mithilfe der Annotation @RunWith an, dass RobolectricTestRunner.class verwendet werden soll. Um sicherzustellen, dass unser Set-up ordnungsgemäß funktioniert, schreiben wir zunächst einen Test, der fehlschlägt (Listing 4).

Listing 4: MyActivityTest.java
package de.javamagazin.robolectricdemojavamagazin;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;

import static org.junit.Assert.assertTrue;

@RunWith(RobolectricTestRunner.class)
public class MyActivityTest {

@Test
public void shouldFail() {
   assertTrue(false);
}
}

Auf der Kommandozeile kann der Test nun mit ./gradlew test ausgeführt werden, das Ergebnis sollte ungefähr wie in Listing 5 aussehen.

Listing 5: Testergebnis, erster Lauf
de.javamagazin.robolectricdemojavamagazin.MyActivityTest > shouldFail FAILED
java.lang.AssertionError at MyActivityTest.java:15

1 test completed, 1 failed
:app:testDebug FAILED

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ‚:app:testDebug‘.

There were failing tests. See the report at: file:///Users/peterfriese/Dropbox/Publications/Articles/JavaMagazin/Robolectric/Code/RobolectricDemoJavaMagazin/app/build/test-report/debug/index.html

Wenn wir nun die Assertation ändern (zu assertTrue(true)), sollte der Test ohne Fehler durchlaufen, siehe Listing 6.

Listing 6: Erfolgreicher Testlauf
app:test

BUILD SUCCESSFUL

Total time: 8.446 secs

Doch nun zum ersten echten Test. Aufgabe des Tests soll es sein, zu prüfen, ob alle UI-Elemente an die entsprechenden Felder in der Unit-Testklasse gebunden sind. Da wir auch in nachfolgenden Tests auf die UI-Elemente zugreifen wollen, führen wir das Binding (mittels findViewById()) in einer Setup-Methode durch, die vor jedem Testlauf ausgeführt wird. Unsere erste Testmethode wird shouldNotBeNull() heißen und wird mithilfe von assertNotNull() sicherstellen, dass tatsächlich alle UI-Elemente gebunden sind. Listing 7 zeigt beide Methoden sowie einen weiteren Test, der überprüft, dass der Button korrekt beschriftet ist.

Listing 7: Setup und erster Test
@Before
public void setup() {
myActivity = Robolectric.buildActivity(MyActivity.class).create().get();

button = (Button) myActivity.findViewById(R.id.button);
textView = (TextView) myActivity.findViewById(R.id.textView);
editText = (EditText) myActivity.findViewById(R.id.editText);
}

@Test
public void shouldNotBeNull() {
assertNotNull(myActivity);
assertNotNull(textView);
assertNotNull(button);
assertNotNull(editText);
}

@Test
public void shouldHaveAButtonThatsLabeledSayHi() {
assertThat((String) button.getText(), equalTo(„Say Hi!“));
}

So weit, so gut. Wir haben uns nun ein wenig daran gewöhnt, Tests zu schreiben und auszuführen. Als Nächstes wollen wir TDD-Prinzipien nutzen, um ein wenig Logik zu implementieren. Wie eingangs kurz beschrieben, soll der Benutzer seinen Namen in das Eingabefeld eingeben. Drückt er dann auf den Button, soll eine Begrüßung im TextView erscheinen.

Da die Setup-Methode vor jedem Testlauf ausgeführt wird, sind auch für diesen Test alle erforderlichen UI-Elemente an die entsprechenden Felder gebunden, sodass die Implementierung der Testmethode sehr einfach ist (Listing 8). Im Test wird zunächst ein fest definierter Name in das Eingabetextfeld geschrieben und dann ein Klick auf den Button ausgelöst. Anschließend wird die Beschriftung des TextViews ausgelesen und mit dem erwarteten Text verglichen.

Listing 8: Test der Hello-World Logik
@Test
public void shouldProduceGreetingWhenButtonPressed() {
editText.setText("Peter");
button.performClick();
assertEquals(textView.getText(), "Hello, Peter!");
}

Dieser Test schlägt natürlich zunächst fehl (ganz, wie von TDD gefordert), da wir noch keinerlei Logik implementiert haben. Dies holen wir nun nach und ergänzen die MyActivity-Klasse nun wie in Listing 9 zu sehen. Eine erneute Ausführung des Tests zeigt, dass unsere Implementierung korrekt ist – zumindest in Bezug auf den Test, den wir formuliert haben. Im echten Leben wäre es jetzt an der Zeit, ein paar Grenzwerttests zu formulieren (z. B.: Was passiert, wenn der Benutzer nichts eingibt, der eingegebene Text extrem lang ist oder Sonderzeichen enthält?).

Listing 9: Die Hello-World-„Logik“
private Button button;
private EditText editText;
private TextView textView;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_my);

editText = (EditText) findViewById(R.id.editText);
textView = (TextView) findViewById(R.id.textView);
button = (Button) findViewById(R.id.button);
button.setOnClickListener(new View.OnClickListener() {
     @Override
     public void onClick(View v) {
       String name = editText.getText().toString();
       String greeting = String.format(„Hello, %s!“, name);
       textView.setText(greeting);
     }
});
}

Im Rahmen dieses Artikels wollen wir uns jedoch noch einige andere Spezialitäten von Robolectric anschauen.

Start von Android-Komponenten testen

Jede nicht triviale Android-App besteht aus einer Vielzahl von Android-Komponenten wie Activities, Fragments, Services, BroadcastListeners etc. Oft möchte man testen, ob als Reaktion auf eine Benutzerinteraktion innerhalb einer Activity die korrekte Folge-Activity oder ein Service gestartet wurde. Mithilfe von Robolectric kann dies auf einfache Art und Weise getestet werden, ohne dass der entsprechende Unit Test dadurch deutlich länger laufen würde. Wird innerhalb einer Activity eine Folge-Activity mittels startActivity gestartet, so wird dies von der entsprechenden Robolectric-Shadow-Klasse abgefangen und vermerkt. Mittels shadowActivity.getNextStartedActivity() kann dann geprüft werden, ob der erwartetet Intent gestartet wurde. Listing 10 zeigt den entsprechenden Testcode. Das gleiche Vorgehen kann verwendet werden, um den Start von Services zu überprüfen, in diesem Fall muss dann die Methode getNextStartedService() verwendet werden.

Listing 10: Start einer Activity testen
@Test
public void shouldNavigateToNextActivity() {
profileButton.performClick();
Intent expectedIntent = new Intent(myActivity, UserProfileActivity.class);

ShadowActivity shadowActivity = Robolectric.shadowOf(myActivity);
Intent startedIntent = shadowActivity.getNextStartedActivity();
assertThat(expectedIntent, equalTo(startedIntent));
}

Wie funktioniert Robolectric?

Robolectric verwendet Shadow-Objekte, um sicherzustellen, dass Unit Tests auf einer regulären JVM ausgeführt werden können. Für jede Klasse aus dem Android-SDK gibt es eine passende Shadow-Implementierung, die die beim Aufruf übergebenen Parameter aufzeichnet, sodass sie zum Ende des Tests abgefragt und mit den erwarteten Werten verglichen werden können. Der Robolectric Test Runner sorgt dafür, dass sich Robolectric beim Start eines Unit Tests in die Classloader-Hierarchie einklinkt und jede von einem Unit Test benötigte Android-Klasse mittels Bytecode-Instrumentierung modifizieren kann. Anstatt eine RuntimeException zu werfen, delegieren die Methoden einer Android-Klasse nun den Aufruf an die jeweilige Methode ihrer Shadow-Klasse, die die Aufrufparameter aufzeichnen kann.

Damit Views ebenfalls getestet werden können, parst Robolectric beim Laden eines Layouts die Layoutdatei und baut einen Baum von View Objects auf, die ihrerseits natürlich ebenfalls Shadow-Objekte verwenden, um Aufrufe aufzuzeichnen.

Fazit

Die Verkürzung der Turnaround-Zeiten ist der größte Vorteil von Robolectric und sorgt dafür, dass das Schreiben von Unit Tests unter Android wieder Spaß macht. Die Integration mit Android Studio leidet leider ein wenig unter den Einschränkungen von Android Studio. Hier heißt es also, auf eine bessere Unterstützung zu warten oder auf IntelliJ auszuweichen. Ein echter Wermutstropfen ist allerdings die derzeit mangelnde Unterstützung für die aktuellen Android-Releases: Die höchste von Robolectric unterstützte Android-Version ist Jelly Bean.

Unsere Redaktion empfiehlt:

Relevante Beiträge

Meinungen zu diesem Beitrag

X
- Gib Deinen Standort ein -
- or -