Repository Testing: Datenschicht zuverlässig absichern
Repository-Tests prüfen die Datenschicht deiner Android-App auf lokalem Speicher, Remote-APIs und Fehlerpfaden. So baust du eine belastbare Architektur.
Die Datenschicht ist das Herzstück jeder gut strukturierten Android-App — und gleichzeitig der Teil, der in Tests am häufigsten vergessen wird. Ein Repository bündelt lokale Datenbank, Remote-API und In-Memory-Cache hinter einer einzigen sauberen Schnittstelle. Repository Testing bedeutet, genau dieses Bündel systematisch zu prüfen: den lokalen Pfad, den Remote-Pfad und — besonders wichtig — alle Fehlerpfade.
Was ist das?
Das Repository-Pattern ist seit Jahren fester Bestandteil der offiziellen Android-Architekturempfehlungen. Ein Repository entscheidet, woher Daten stammen: Es kann Room-Zeilen aus der lokalen Datenbank zurückgeben, einen Retrofit-Call abfeuern oder gecachte Werte aus dem Speicher liefern. Der ViewModel merkt den Unterschied nicht — das ist Absicht.
Repository Testing bezeichnet alle automatisierten Tests, die sicherstellen, dass dieses Routing korrekt funktioniert. Die Szenarien lassen sich in drei Gruppen einteilen:
- Lokaler Pfad: Das Repository liest aus der Room-Datenbank und gibt die Daten korrekt gemappt zurück.
- Remote-Pfad: Das Repository ruft die API auf, verarbeitet die Antwort und persistiert sie optional im lokalen Cache.
- Fehlerpfad: Netzwerkausfall, HTTP-Fehler (4xx/5xx) und leere Antworten führen zur erwarteten Fehlerbehandlung — etwa einem
Result.failureoder einer domänenspezifischen Exception.
Wer Repository-Tests überspringt, verlässt sich darauf, dass ViewModel-Tests oder End-to-End-Tests diese Lücke schließen. Das ist ein unsicherer Plan: Bugs in der Datenschicht tauchen dann spät auf und sind teuer zu debuggen.
Wie funktioniert es?
Das zentrale Werkzeug sind Interface-basierte Abhängigkeiten kombiniert mit Fake-Implementierungen. Statt das Repository direkt mit einem echten Retrofit-Client oder einer echten RoomDatabase zu verdrahten, definierst du für jede Datenquelle ein Interface und injizierst es per Konstruktor.
interface UserRemoteDataSource {
suspend fun fetchUser(id: String): Result<UserDto>
}
interface UserLocalDataSource {
suspend fun getUser(id: String): UserEntity?
suspend fun saveUser(user: UserEntity)
}
Im Test erstellst du einfache Fake-Klassen, die dieses Interface implementieren und vorhersagbare Werte liefern:
class FakeUserRemoteDataSource : UserRemoteDataSource {
var result: Result<UserDto> = Result.success(UserDto("1", "Anna"))
var callCount = 0
override suspend fun fetchUser(id: String): Result<UserDto> {
callCount++
return result
}
}
Das Repository bekommt im Test die Fake-Instanzen per Konstruktor — kein Mocking-Framework nötig:
val local = FakeUserLocalDataSource(existing = UserEntity("1", "Anna"))
val remote = FakeUserRemoteDataSource()
val repo = UserRepositoryImpl(local, remote)
Mit kotlinx-coroutines-test und runTest lässt sich suspend-Code direkt in JUnit-Tests ausführen, ohne echte Verzögerungen abzuwarten. Der Test läuft vollständig auf der JVM in Millisekunden.
Cache-Logik als eigene Testfälle
Cache-Entscheidungen — „Lade ich aus dem Netz oder lese ich aus dem lokalen Store?” — verdienen eigene Tests. Eine typische Strategie ist Cache-First: Zeige lokale Daten sofort und hole im Hintergrund aktuellere Daten nach. Für diese Logik brauchst du mindestens zwei Tests: einen für den Cache-Hit (Remote wird nicht aufgerufen) und einen für den Cache-Miss (Remote wird aufgerufen und das Ergebnis wird persistiert).
In der Praxis
Hier ist ein vollständiger, praxisnaher Testblock für ein Repository, das zuerst lokal nachschaut und nur bei Cache-Miss die API aufruft:
class UserRepositoryTest {
private lateinit var local: FakeUserLocalDataSource
private lateinit var remote: FakeUserRemoteDataSource
private lateinit var repo: UserRepositoryImpl
@Before
fun setup() {
local = FakeUserLocalDataSource()
remote = FakeUserRemoteDataSource()
repo = UserRepositoryImpl(local, remote)
}
@Test
fun `gibt gecachten User zurueck ohne Remote-Call`() = runTest {
local.storedUser = UserEntity("1", "Anna")
val result = repo.getUser("1")
assertThat(result.getOrNull()?.name).isEqualTo("Anna")
assertThat(remote.callCount).isEqualTo(0)
}
@Test
fun `laed von Remote bei Cache-Miss und persistiert Ergebnis`() = runTest {
local.storedUser = null
remote.result = Result.success(UserDto("42", "Ben"))
val result = repo.getUser("42")
assertThat(result.isSuccess).isTrue()
assertThat(local.savedUser?.name).isEqualTo("Ben")
}
@Test
fun `gibt Failure zurueck bei Netzwerkfehler`() = runTest {
local.storedUser = null
remote.result = Result.failure(IOException("no network"))
val result = repo.getUser("42")
assertThat(result.isFailure).isTrue()
assertThat(result.exceptionOrNull()).isInstanceOf(IOException::class.java)
}
}
Typische Stolperfalle — leere Antworten: Viele Entwickler testen nur den offensichtlichen Fehlerfall (Exception) und vergessen, dass eine API mit HTTP 200 antworten und trotzdem eine leere Liste zurückgeben kann. Das Repository muss entscheiden, ob das ein valider Zustand oder ein Fehler ist — und genau diese Verzweigung braucht einen eigenen Test. Ohne ihn landet die Entscheidung ungetestet im ViewModel oder sogar in der UI.
Ein weiterer Klassiker: Fake-Datenquellen modellieren nur den Erfolgspfad. Ergänze deine Fakes um eine shouldThrow-Property, um SQLiteException, IOException oder HTTP-Fehlercodes zu simulieren, und stelle sicher, dass dein Repository in jedem dieser Fälle einen definierten Zustand zurückgibt — kein ungefangenes throw aus der Datenschicht sollte je den ViewModel erreichen.
Fazit
Repository Testing sichert den sensibelsten Teil deiner App-Architektur ab. Wer die drei Pfade — lokal, remote, Fehler — systematisch mit Fake-Datenquellen abdeckt, findet Cache-Bugs und Fehlerpfad-Lücken lange bevor sie im Review oder auf dem Gerät eines Nutzers auftauchen. Als nächsten Schritt öffne eines deiner bestehenden Repositories, schreibe gezielt einen Test für einen Fehlerpfad, den du bisher nicht abgedeckt hast, und prüfe im Debugger, welchen Wert dein ViewModel in diesem Fall tatsächlich empfängt — die Antwort wird dich überraschen.