Testgetriebene Android-Entwicklung mit Robolectric und Mockito

Von Cocktails und Robotern
Kommentare

Robolectric ermöglicht einem Entwickler ohne große Umwege das Testen von Android in der JVM. In Kombination mit Frameworks wie Mockito entsteht so ein sehr mächtiger Werkzeugkasten, mit dem man sehr schnell die ersten Tests – sogar hunderte davon – meistern kann.

Wer kennt das nicht? Man findet in einem Projekt eine bestehende Anwendung vor, die zwar ganz passabel läuft, jedoch über keinen einzigen Unit Test verfügt. Streckenweise mag so etwas funktionieren, gerade in kurzlebigen Projekten. Prekär wird es allerdings in einer längeren Entwicklung. Wenn man obendrein vielleicht die Weiterentwicklung auf Grundlage von agilen Methoden durchführen möchte, sind jede Menge Tests ein absolutes Muss. Doch was ist der beste Weg? Lässt sich Android überhaupt testgetrieben entwickeln? Obwohl wir in einer Hightech-Branche arbeiten, gibt es immer noch jede Menge Projekte, die ohne eine einzige Zeile Tests auskommen. Gerade im Mobile-Bereich scheint oft ein gewisser Nachholbedarf zu bestehen. Dabei sollten vor allem dort, in diesem schnelllebigen Bereich, Unit Tests zur Grundausstattung gehören. Erst sie ermöglichen Änderungen mit einschätzbarem Risiko. Zudem unterstützen sie die Kommunikation und schützen vor Fehlern – denn Fehler können und werden zwar passieren, sollten aber niemals wiederkehren.

Aller Anfang ist schwer

Einen Einstieg ins Testen zu finden ist manchmal gar nicht so einfach. Für viele Entwickler ist existierender Code gar untestbar. Trifft man auf ein bestehendes Projekt, so ist meiner Erfahrung nach exploratives Testen ein guter Weg, um die Anwendung kennen zu lernen. So entstehen die Tests nebenbei. Auch vorhandene Google Play Crashlogs sind ein guter Einstieg, um Tests zu schreiben und sorgen sofort für das Absichern gegen die Wiederkehr eines Bugs. Gerade bei der Entwicklung im Team ist das von großem Vorteil. Der typische Ansatz, auf Android zu testen, führt über die „Android Unit Tests“, wie sie von Google direkt unterstützt werden. Grundsätzlich unterscheidet man hier zwei Arten von Tests: typische Unit Tests (Testklassen erben von JUnit) und Tests, die mit dem UI interagieren (Stichwort ActivityInstrumentationTestCase2). Beide Arten von Tests müssen im Simulator bzw. auf einem Android-Gerät laufen. Während das bei den GUI-basierten Tests sinnvoll ist, bringt es für klassische Unit Tests einige Nachteile mit sich – die größten sind: Performance, Performance und nochmals: Performance.

Unit Tests müssen schnell ablaufen! Beim Android-Ansatz wird jedoch erst eine Android-Test-App erstellt, diese Anwendung auf dem Gerät installiert – inklusive der zu testenden Anwendung – und dann gestartet, und das alles per Android Debug Bridge (ADB). Das dauert jedoch seine Zeit. Der Ablauf der Tests an sich nimmt zudem deutlich mehr Zeit in Anspruch, als man es als Java-Entwickler gewohnt ist. Da können für einen Test schon einmal ein paar Sekunden vergehen. Das mag nach wenig klingen. In dieser Zeit könnten jedoch hunderte (!) JUnit-Tests in der JVM laufen. Wieso ist das so wichtig? Der Entwickler sollte seine Tests so oft wie irgend möglich laufen lassen, im Idealfall nach jeder Änderung, noch besser: die ganze über Zeit nebenher. Jede Verzögerung, also jedes Warten auf Tests, führt zu schlechter Produktivität und meist dazu, dass am Ende weniger Tests geschrieben werden oder diese einfach nicht mehr laufen. Eine testgetriebene Entwicklung, bei der man den Test zuerst schreibt und dann den zu testenden Code, ist auf diese Weise einfach nicht möglich. Der Test läuft alle paar Codezeilen. So führt das Android-Testverfahren zu einer Produktivitätsbremse. Es verdirbt dem Entwickler den Spaß, und ohne Spaß am Testen wird die Anzahl der Tests stagnieren oder gar abnehmen. Das Framework scheint also nicht geeignet für diesen Ansatz.

Warum also nicht in der klassischen Java VM testen? An dieser Stelle wird gern darauf hingewiesen, dass Tests ja nur aussagekräftig sind, wenn sie auf der Zielplattform direkt laufen. Wer so argumentiert, verwischt jedoch oft die Grenzen zwischen Unit Tests und Integrations- oder Akzeptanztests, für die das Android-Framework durchaus hilfreich ist. Aber ein Backend-Entwickler würde auch nicht auf die Idee kommen, gegen echte Datenbanken auf einer großen Serverfarm seine alltäglichen Tests durchzuführen. Seine Unit Tests laufen in der IDE ab. Nun könnte man entgegnen, und das Argument ist mir oft untergekommen, dass die Android Dalvik VM gerade keine offizielle Java VM ist und daher die Tests verfälscht werden könnten. Darauf haben glücklicherweise einige Google-Entwickler (außerhalb des Android-Teams) vor einer Weile direkt geantwortet:

„In practice (with programs of several thousand lines of code), we haven’t discovered any significant differences between the two VMs, and we would consider any difference to be a bug in one or the other VM.“

Aufgrund der Unterschiede in den VMs ergibt sich für den geübten Unit-Test-Entwickler jedoch noch ein ganz anderes Problem, wenn sie auf die Android VM setzen: Viele Frameworks im Testbereich setzen auf modifizierte Class-Loading-Mechanismen. Diese laufen jedoch oft nicht auf Android-Geräten. Das betrifft z. B. geläufige Mock-Frameworks (mehr dazu später). All diese Gründe sprechen dafür, Android-Tests in der JVM laufen zu lassen. Versuchen wir dies doch an einem einfachen Beispiel. Schauen wir uns eine Funktion an, die es zu testen gilt (Listing 1).

static Intent createIntentForQuery(Context context, String query, String value) {
  Intent i = new Intent(context, AdListActivity.class);
  i.putExtra(AppConstants.QUERY, query);
  i.putExtra(AppConstants.VALUE, queryString);
  return i;
}

Diese kleine Factory-Methode legt einen Intent an, mit dem wir eine entsprechende Activity starten können. Zu testen wäre hier z. B., dass die so genannten Extras korrekt befüllt sind. Um die Funktion aufzurufen, benötigen wir zuallererst einen Context. Hier haben wir mehrere Möglichkeiten:

  • Context ist eine abstrakte Klasse, wir könnten uns also eine Implementierung dafür schreiben: einen Stub. Nachteil: Wir schreiben jede Menge Boilerplate-Code. Selbst wenn er uns generiert wird, macht dies unsere Tests unlesbarer, und gerade Tests sollten ja eine Form der Dokumentation sein.
  • Die zweite Möglichkeit: Wir erzeugen uns eine reale Implementierung, z. B. die Activity, die wir auch real an dieser Stelle nutzen würden. Diesen Gedanken sollten wir jedoch verwerfen, denn Tests sollten wenige Abhängigkeiten haben. So würden wir jedoch die Isolation, die ein solcher Test haben sollte, aufgeben.
  • Dritte Möglichkeit: Wir erzeugen einen Mock!

Was ist ein Mock? Martin Fowler beschreibt Mocks als „test-doubles…pre-programmed with expectations“. Die Idee ist: Benötigt man ein Objekt für den Test, so erstellt man sich programmatisch einen Mock, dem man sein Verhalten einimpft. Die Magie dahinter übernimmt das jeweilige Framework, von denen es einige gibt: EasyMock, jMock, Mockito usw. Für unseren Test nutzen wir Mockito. Unseren benötigten Context bekommen wir wie folgt:

Context context = Mockito.mock(Context.class);

Fertig ist ein Objekt, das wir als „Double“ hereinreichen können. Beginnen wir also unseren Test zu schreiben, der sicherstellt, dass unsere Factory-Methode ein Objekt zurückgibt (Listing 2). Wenn wir diesen Test nun laufen lassen, stoßen wir auf ein Problem. Uns fliegt eine Exception um die Ohren, die in etwa wie folgt aussieht:

import static org.junit.Assert.*;
import static org.mockito.Mockito.*;
import org.junit.Test;
...
@Test
public void testCreateIntentForQuery() {
    Context context = mock(Context.class);
Intent intent = MyActivity.createIntentForQuery(context, "key", "value");
assertNotNull(intent);
}

java.lang.RuntimeException: Stub! at android.content.Intent.(Intent.java:29) 

Das Problem: Wir haben hier ein Objekt aus dem Android SDK nutzen wollen: Der zu testende Code erzeugt einen Intent. Hätten wir nur Java-Klassen genutzt, wäre das kein Problem gewesen, aber sobald wir in die Bereiche des Android SDK kommen, also beispielsweise alles in den Paketen die mit android.* beginnen, streikt JUnit bzw die JVM.
Damit entfallen viele Basics. Das Testen von Activities wird komplett unmöglich, da dazu der Konstruktor einer Activity durchlaufen werden muss, egal, wie isoliert die Klasse ist. Das Problem ist aber weitergehender Natur: Nicht einmal einen Mock kann man generieren von einer Klasse wie Intent. Nun kann man seinen Code entsprechend umbauen, keinerlei Businesscode in den Activities nutzen und jegliche Klasse des Android SDK wrappen (wer schon einmal einen Unit Test für BlackBerry-Java-Anwendungen geschrieben hat, kennt diese Variante). Aber wir erzeugen damit einen gewaltigen Overhead, und eine Option bei einem bestehenden Projekt ist das leider auch nicht. Abhilfe schafft hier das Framework Robolectric. Robolectric ersetzt jede Klasse der Android VM zur Laufzeit durch so genannte Shadow-Objekte. Wie das genau funktioniert und was damit weiterhin möglich ist, dazu später mehr. Vorerst ist wichtig: Robolectric ermöglicht das Testen gegen das Android-Framework direkt in der JVM. Die Modifikation in unserem Test ist simpel, er muss nur mit Roboletric ausgeführt werden. Das lässt sich per Annotation festlegen:

@RunWith(RobolectricTestRunner.class)
public class AdListActivityTest {
 
@Test
public void testCreateIntentForQuery() {
    Context context = Mockito.mock(Context.class);
Intent intent = MyActivity.createIntentForQuery(context, "key", "value");
    assertNotNull(intent);
Bundle extras = intent.getExtras();
assertNotNull(extras);
assertEquals("key", extras.getString(AppConstants.QUERY));
assertEquals("value", extras.getString(AppConstants.VALUE));
}

Und schon läuft der Test sauber durch! Schauen wir uns den kompletten Test an (Listing 3). Dank Robolectric konnten wir Intents genauso benutzen, wie wir es als Android-Entwickler gewohnt sind: Extras sind mit getString() etc. abrufbar, sind also korrekt befüllt worden, ohne dass wir dies für den Test simulieren mussten. Das gehört zur Magie, die uns das Framework hier mitbringt. Es stellt also mehr als nur „dumme“ Klassen zur Verfügung. Das Schöne an Mocking-Frameworks ist, dass wir das Verhalten der Mocks sehr gut steuern können. Wir könnten beispielsweise sicherstellen, dass die zu testende Funktion nichts auf dem Context aufruft, was wir nicht erwarten. Schauen wir uns dies an einem Beispiel an. Die Funktion einer fiktiven Klasse Utils gibt die Geräte-ID eines Android-Telefons zurück (Listing 4).

public static String getDeviceId(Context context) {
TelephonyManager tm = (TelephonyManager) 
             context.getSystemService(Context.TELEPHONY_SERVICE);
 if (tm != null) {
return tm.getDeviceId();
 }
 return "";
}

Auch hier brauchen wir wieder einen Context. Diesmal wird auf ihm jedoch eine Methode getSystemService() aufgerufen. Unser Mock weiß natürlich nichts von einem TelephonyManager und kann daher einen solchen auch nicht zurückgeben. Wir müssen ihm daher ein wenig Verhalten beibringen. Neben dem Context lassen wir uns auch noch einen TelephonyManager erzeugen (Listing 5). Nun impfen wir dem Context ein, dass er auf Anfrage einen TelephonyManager zurückgibt. Das geschieht über eine gut lesbare Wenn-Dann-Syntax:

public static String testGetDeviceId(Context context) {
     Context cont = mock(Context.class);
   TelephonyManager mgr = mock(TelephonyManager.class);
}

 Mockito.when(cont.getSystemService(Context.TELEPHONY_SERVICE)).thenReturn(mgr);

Auf diese Weise überprüfen wir zudem auch, dass getSystemService mit dem korrekten Parameter (TelephonyManager) aufgerufen wird. Für den Fall, dass man den Parameter nicht kennt oder testen möchte, könnte man auch Mockito.anyString() als Argument nutzen. Der komplette Text ist in Listing 6 zu sehen.

@Test
public void getDeviceId() {
Context cont = mock(Context.class);
TelephonyManager mgr = mock(TelephonyManager.class);
when(mgr.getDeviceId()).thenReturn("123456");
when(cont.getSystemService(Context.TELEPHONY_SERVICE)).thenReturn(mgr);
    assertEquals("123456", Utils.getDeviceId(cont));
}
Behavior vs. State Testing
Entwickler, die sehr viel auf Mock-Frameworks setzen, entwickeln schnell eine andere Art, ihre Tests zu schreiben. Getestet wird weniger das Endergebnis (also der Zustand), wie im klassischen Unit Test, als der Weg dorthin. Es wird geprüft, wie sich das zu testende Objekt verhält: welche Funktionen aufgerufen werden, welche nicht. Wenn die richtigen Funktionen in der korrekten Reihenfolge benutzt werden, mit den richtigen Argumenten, ist das Ergebnis vorhersehbar, sofern auch diese Funktionen getestet werden. Auf diese Weise entstehen sehr feingranulare Tests, die Seiteneffekte wesentlich besser ausschließen können. Fängt man erst einmal an das Verhalten zu testen, so wirkt sich das z. B. auch auf die Benennung der Testmethoden aus: Sie fangen z. B. mit dem Wörtchen should an. Mehr zum Thema Behavior Driven Development findet sich hier.

[ header = Ein wenig Zuckerguss … ]

Ein wenig Zuckerguss …

Wie anfangs erwähnt, bringt Robolectric einiges an Magie mit. Darauf wollen wir noch einen Blick werfen. Android basiert sehr stark auf XML-Dateien: Resource Strings werden dort genauso definiert wie die Layouts des UI. Wenn wir mit unserem Projekt eine hohe Codeabdeckung erreichen wollen, müssen wir auch GUI-Klassen testen. Man denke nur einmal daran, was üblicherweise alles in der onCreate()– oder onResume()-Methode einer Activity passiert. Robolectric schaut sich diese XML-Dateien an und füttert die erzeugten Shadow-Objekte damit. Man kann also getString() oder findViewById() in den Tests verwenden. Schauen wir uns dies an einem Beispiel an: Die Methode in Listing 7 setzt einer TextView eine Telefonnummer. Sollte keine Nummer existieren, wird stattdessen die ganze TextView versteckt.

protected void setVisibilityOfPhoneNumber(String phoneNumber) {
TextView tv = (TextView)findViewById(R.id.callText);
if (phoneNumber != null && phoneNumber.length() > 0) {
      tv.setText(phoneNumber);
} else {
      tv.setVisibility(View.GONE);
}
}

Wollte man hier mit Mockito testen, so müsste man erst einmal ein Refactoring der Methode durchführen. Das Setzen der Sichtbarkeit und des Textes sollte unabhängig von findViewById passieren (damit wäre man dann auch konform mit den Regeln von Clean Code). Anschließend könnte man einen Mock für die TextView erzeugen und die Methode endlich testen, in etwa so:

 when(viewmock.findViewById(R.id.callText)).thenReturn(tvmock)

Bei einer großen bestehenden Codebasis ist es eventuell aber schwieriger, jede Methode erst einmal zu zerlegen. Dank Robolectrics Magie lässt sich die Methode in Listing 7 auch wie in Listing 8 testen.

@Test
public void setVisibilityOfPhoneNumber() {
AdDetailsActivity activity = new AdDetailsActivity();
activity.onCreate();
activity.setVisibilityOfPhoneNumber("");
TextView tv = (TextView) activity.findViewById(R.id.callText);
assertEquals(View.GONE, tv.getVisibility());
}

Dieser Test kommt nun ganz ohne Mocking aus. Es ist nicht unbedingt der perfekte Unit Test, da er viele Abhängigkeiten hereinbringt (was macht onCreate oder der Activity-Konstruktor?). Andererseits ist dieser Test näher an Code, wie ihn ein Android-Entwickler üblicherweise schreiben würde. So fällt der Einstieg in das Testen dem einen oder anderen möglicherweise leichter. Und wie erwähnt: In existierenden Projekten ist es eine große Hilfe.

Die Magie hinter Robolectric

Was genau macht Robolectric? Im Prinzip wird das Laden der zu testenden Klasse abgefangen und mithilfe von Javassist (ein Bytecode-Manipulations-Framework), dessen Methoden zur Laufzeit neu geschrieben werden, sodass sie statt der „Stub“-Exception nun alle Aufrufe an ein so genanntes Shadow-Objekt weiterreichen. Diese gibt es von Robolectric für die meisten der Klassen im SDK, beispielsweise ShadowImageView für ImageView usw. Einige dieser Klassen speichern ihren Status, sodass man mit ihnen ganz normal im Android-Stil arbeiten kann. Man kann über das Robolectric-API sogar das Shadow-Objekt direkt abfragen, um z. B. einen internen Status zu erfahren. Das Ganze ist zudem so aufgebaut, dass auch neue API-Klassen und -Methoden die Shadow-Objekte nicht sogleich brechen. Eigene Shadow-Implementierungen können außerdem auch hinzugefügt werden.


Code Coverage

Einer der Vorteile von JUnit ist die Tool-Unterstützung. Wir können nun nicht nur diese Tests in jeder Art von Umgebung ausführen – jede denkbare Entwicklungsumgebung ist möglich, auch Build-Tools wie Ant, Maven oder CI-Server wie Jenkins (Abb. 1). Dazu kommen Frameworks wie z. B. eine Coverage-Analyse. In einem modernen Entwicklungsprozess ist die Prozentzahl der Codeabdeckung der Tests ein wichtiges (wenn auch allein nicht aussagekräftiges) Kriterium für die Bewertung der Unit Tests (eine detaillierte Testabdeckung ist in Abbildung 2 zu sehen). Dank Robolectric lassen sich gängige Coverage-Java-Tools für JUnit einsetzen.

Abb. 1:




Testabdeckung im Zeitverlauf auf einem Jenkins-Server

Abb. 2:




Detaillierte Testabdeckung der Pakete

Tipps zum Ende

Die Integration in die einzelnen Entwicklungsumgebungen wurde im Rahmen dieses Artikels nicht erwähnt. Die Robolectric-Seite enthält jedoch ausführliche Anleitungen dazu, wie man sein Projekt aufsetzen sollte. Ein Tipp: Sollte der Leser diesen Test ausführen und folgende Meldung erhalten:

java.lang.RuntimeException: java.io.FileNotFoundException: AndroidManifest.xml not found or not a file; it should point to your project's AndroidManifest.xml
We

so liegt das daran, dass Robolectric den Pfad zum Manifest benötigt. Jeder Test muss im Kontext des Android-Projekts laufen. In Eclipse bedeutet das z. B. das Working Directory auf das des Android-Projekts zu setzen, selbst wenn der Test in einem eigenen Java-Projekt läuft. Mockito-Profis wird aufgefallen sein, dass Tests, wenn sie auf den Robolectric Runner setzen, nicht mehr mit dem Mockito Runner laufen können (dies ermöglicht noch einige weitere Features von Mockito). Das ist in der Tat ein Nachteil. Daher wurde in diesem Artikel nur auf Features hingewiesen, die mit Robolectric kompatibel sind.

Zu guter Letzt seien auch einige Grenzen von Mocks erwähnt: Probleme gibt es mit Mockito beispielsweise bei privaten oder finalen Methoden. An dieser Stelle sei auf das Projekt PowerMock hingewiesen, das sich genau damit beschäftigt hat.

 

Unsere Redaktion empfiehlt:

Relevante Beiträge

Meinungen zu diesem Beitrag

X
- Gib Deinen Standort ein -
- or -