Instrumentierte Tests: Echtes Android-Framework-Verhalten testen
Instrumentierte Tests laufen direkt auf einem Gerät oder Emulator. Sie prüfen echtes Android-Framework-Verhalten, das Unit Tests nicht abbilden können.
Wer Android-Apps entwickelt, stößt früh auf eine Grenze von Unit Tests: Sie laufen in der JVM, ohne ein echtes Android-System. Sobald dein Code jedoch auf Activities, Content Provider, Broadcast Receiver, Permissions oder systemnahe APIs zugreift, braucht du einen anderen Ansatz — und genau dafür gibt es instrumentierte Tests.
Was ist das?
Instrumentierte Tests sind Testklassen, die nicht in der lokalen JVM, sondern direkt auf einem Android-Gerät oder Emulator ausgeführt werden. Das Wort „instrumentiert” stammt aus dem Konzept der Android Instrumentation: Das Betriebssystem stellt dem Testcode eine vollständige Laufzeitumgebung inklusive Context, Application und aller Framework-Klassen bereit.
Im Unterschied zu Unit Tests, die mit Mockito oder ähnlichen Bibliotheken externe Abhängigkeiten imitieren, sprechen instrumentierte Tests das echte Framework an. Das bedeutet: Activity-Lebenszyklen spielen sich genauso ab wie in der Produktion, Ressourcen werden aus dem APK geladen, und Room-Datenbanken laufen auf dem tatsächlichen Android-Datenbanksubsystem.
Im Android-Testpyramid stehen instrumentierte Tests an der Spitze der Kategorie Integration und End-to-End. Sie sind langsamer und ressourcenintensiver als Unit Tests, liefern aber echten Realismus, den keine Simulation vollständig ersetzen kann. Genau dieser Realismus ist ihr Alleinstellungsmerkmal: Was auf dem Emulator grün ist, verhält sich wie in der Produktion.
Wie funktioniert es?
Instrumentierte Tests leben im Verzeichnis src/androidTest/ deines Moduls, klar getrennt vom src/test/-Ordner für Unit Tests. Gradle erkennt diesen Unterschied automatisch und baut einen separaten Test-APK, der zusammen mit dem App-APK auf das Gerät übertragen wird.
Das Rückgrat der Infrastruktur bildet AndroidX Test, das drei wesentliche Bausteine liefert:
AndroidJUnit4: Der Test-Runner, der JUnit 4-Annotationen (@Test,@Before,@After) innerhalb der Android-Laufzeit ausführt.ActivityScenarioRule: Startet und beendet eine Activity kontrolliert pro Test, ohne manuelle Lifecycle-Verwaltung.ApplicationProvider: Stellt dir einenContextbereit, der zum laufenden App-Prozess gehört, damit du Ressourcen oder SharedPreferences in Tests lesen kannst.
Für UI-Tests kommt Espresso hinzu. Espresso synchronisiert sich automatisch mit dem Main-Thread und dem internen IdlingResource-Mechanismus, sodass Assertions genau dann ausgeführt werden, wenn die UI stabil ist — du brauchst keine manuellen sleep-Aufrufe. Für komplexere Szenarien wie das Testen von System-UI oder mehrerer Apps bietet der UI Automator tieferen Zugriff auf den Accessibility-Tree des Geräts.
Dependency Injection mit Hilt rundet das Bild ab: Die Annotation @HiltAndroidTest zusammen mit der HiltAndroidRule erlaubt es, Test-Varianten deiner Module einzuhängen. So kannst du trotz echtem Framework das Verhalten einzelner Schichten isoliert und reproduzierbar prüfen, ohne auf produktiven Netzwerkaufrufe oder echte Datenbankinhalte angewiesen zu sein.
In der Praxis
Stell dir vor, deine LoginActivity navigiert nach einem erfolgreichen Login zur HomeActivity. Dieser Übergang hängt vom echten Activity-Backstack ab — ein Unit Test kann ihn nicht abbilden.
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class LoginActivityTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val activityRule = ActivityScenarioRule(LoginActivity::class.java)
@Before
fun setUp() {
hiltRule.inject()
Intents.init()
}
@After
fun tearDown() {
Intents.release()
}
@Test
fun successfulLogin_navigatesToHome() {
onView(withId(R.id.etEmail))
.perform(typeText("[email protected]"), closeSoftKeyboard())
onView(withId(R.id.etPassword))
.perform(typeText("password123"), closeSoftKeyboard())
onView(withId(R.id.btnLogin)).perform(click())
intended(hasComponent(HomeActivity::class.java.name))
}
}
Typische Stolperfalle: Intents nicht freigeben
Intents.init() registriert einen globalen Intent-Interceptor für den laufenden Test. Vergisst du Intents.release() im @After-Block, akkumulieren sich Intent-Einträge über mehrere Tests hinweg. Nachfolgende Tests sehen dann Intents aus früheren Läufen und liefern falsch positive Ergebnisse, die schwer zu debuggen sind. Verwende daher konsequent das init/release-Paar oder greife zur IntentsRule als @get:Rule, die diese Verwaltung automatisch übernimmt.
Entscheidungsregel: Greif zu instrumentierten Tests, wenn dein Code einen Context benötigt, den du nicht sauber mocken kannst, wenn Room-Migrationen validiert werden müssen, wenn Systemdialoge oder Laufzeit-Permissions ausgelöst werden, oder wenn das Navigationsverhalten zwischen Activities korrekt sein muss. Für reine Berechnungen, ViewModels mit testbarer Logik oder Repository-Tests mit gefakten Datenquellen sind schnellere Unit Tests die wartbarere Wahl.
Fazit
Instrumentierte Tests sind das Werkzeug, das du benötigst, wenn das Android-Framework selbst Teil des zu testenden Verhaltens ist. Sie bringen echten Realismus auf Kosten von Ausführungszeit — ein Kompromiss, den du bewusst eingehen solltest. Überprüfe in deinem aktuellen Projekt, welche Tests bereits im androidTest/-Verzeichnis liegen und ob sie tatsächlich Framework-Abhängigkeiten abdecken. Schreib mindestens einen neuen instrumentierten Test für eine Activity oder einen Room-DAO, führe ihn auf einem Emulator aus, und beobachte, wie Espresso mit dem UI-Thread synchronisiert. Dieses aktive Ausprobieren ist der direkteste Weg, um den Unterschied zwischen simuliertem und echtem Framework-Verhalten dauerhaft zu verstehen und bewusst in deiner Teststrategie einzusetzen.