Android Coden
Android 4 min lesen

ViewModel Testing: Zustände und Events zuverlässig testen

ViewModel-Tests prüfen Zustandsübergänge und Ereignisbehandlung isoliert. So erkennst du Fehler in der UI-Logik früh und zuverlässig.

Wer in modernen Android-Apps Fehler frühzeitig erkennen will, testet nicht nur die Oberfläche – er testet die Logik dahinter. Das ViewModel ist die zentrale Schaltstelle zwischen Daten und UI, und genau hier lohnt es sich, automatisierte Tests zu schreiben, die Zustandsübergänge und Ereignisbehandlung überprüfen, bevor der Emulator überhaupt startet.

Was ist das?

ViewModel Testing bezeichnet das isolierte Testen von ViewModels ohne echte UI, ohne Emulator und ohne den gesamten Android-Framework-Stack. Ein ViewModel hält den UI-Zustand als StateFlow, löst Einmal-Ereignisse über einen SharedFlow aus und delegiert Datenzugriffe an Repositories. Diese drei Verantwortlichkeiten lassen sich einzeln prüfen, wenn du das ViewModel in einem einfachen JVM-Test instantiierst und seine Ausgaben direkt beobachtest.

In der Android-Architektur ist das ViewModel die einzige Schicht, die dauerhaft über Konfigurationsänderungen hinweg überlebt. Fehler in der Zustandsverwaltung – ein versehentlich zurückgesetzter Ladeindikator, ein doppelt gefeuertes Navigationsereignis – sind schwer im UI-Test zu reproduzieren, aber trivial in einem Unit-Test nachzubilden, weil du den Auslöser präzise kontrollierst.

Wie funktioniert es?

Der Kern moderner ViewModel-Tests liegt in zwei Bausteinen: runTest und einem TestDispatcher.

runTest ist die Coroutinen-aware Testfunktion aus kotlinx-coroutines-test. Sie startet einen synthetischen Coroutinen-Scope, der Coroutinen virtuell vorantreibt, ohne auf echte Zeit warten zu müssen. Selbst ein delay(5_000) läuft in einem runTest-Block innerhalb von Millisekunden ab.

TestDispatcher ersetzt Dispatchers.Main und Dispatchers.IO im Test. Ohne Ersatz würde jedes viewModelScope.launch { } versuchen, den Android-Looper zu nutzen – der in JVM-Tests nicht existiert. Die offizielle Empfehlung lautet, im ViewModel einen injizierten Dispatcher zu verwenden und im Test UnconfinedTestDispatcher oder StandardTestDispatcher einzusetzen.

Für die Zustandsbeobachtung liest du viewModel.uiState.value direkt oder sammelst Emissionen mit einem separaten Coroutinen-Job, der collect aufruft. Für Ereignisse über einen SharedFlow abonnierst du vor dem Auslöser und prüfst anschließend die gesammelten Werte. Diese Reihenfolge ist entscheidend – mehr dazu im nächsten Abschnitt.

Abhängigkeiten im Test ersetzen

Das ViewModel bekommt seine Abhängigkeiten – etwa ein Repository – über den Konstruktor. Im Test ersetzt du das echte Repository durch einen Fake oder Mock, der bekannte Werte zurückgibt. Das gibt dir vollständige Kontrolle über jedes Szenario und macht Tests deterministisch, unabhängig von Netzwerk oder Datenbank.

In der Praxis

Angenommen, dein LoginViewModel setzt bei erfolgreichem Login den Zustand auf Success und löst ein Einmal-Navigationsereignis aus:

class LoginViewModel(
    private val authRepository: AuthRepository,
    private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) : ViewModel() {

    private val _uiState = MutableStateFlow<LoginUiState>(LoginUiState.Idle)
    val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()

    private val _events = MutableSharedFlow<LoginEvent>()
    val events: SharedFlow<LoginEvent> = _events.asSharedFlow()

    fun onLoginClicked(email: String, password: String) {
        viewModelScope.launch(dispatcher) {
            _uiState.value = LoginUiState.Loading
            val result = authRepository.login(email, password)
            _uiState.value = if (result.isSuccess) LoginUiState.Success else LoginUiState.Error
            if (result.isSuccess) _events.emit(LoginEvent.NavigateToHome)
        }
    }
}

Ein Test, der sowohl den Zustandsübergang als auch das Ereignis prüft:

@Test
fun `erfolgreicher Login setzt Success-Zustand und sendet NavigateToHome`() = runTest {
    val fakeRepo = FakeAuthRepository(loginResult = Result.success(Unit))
    val testDispatcher = UnconfinedTestDispatcher(testScheduler)
    val viewModel = LoginViewModel(
        authRepository = fakeRepo,
        dispatcher = testDispatcher
    )

    val states = mutableListOf<LoginUiState>()
    val stateJob = launch(testDispatcher) {
        viewModel.uiState.collect { states.add(it) }
    }

    val collectedEvents = mutableListOf<LoginEvent>()
    val eventJob = launch(testDispatcher) {
        viewModel.events.collect { collectedEvents.add(it) }
    }

    viewModel.onLoginClicked("[email protected]", "secret")

    assertThat(states).containsExactly(
        LoginUiState.Idle,
        LoginUiState.Loading,
        LoginUiState.Success
    ).inOrder()
    assertThat(collectedEvents).containsExactly(LoginEvent.NavigateToHome)

    stateJob.cancel()
    eventJob.cancel()
}

Typische Stolperfalle: SharedFlow ohne Abonnent

Ein SharedFlow mit replay=0 (Standard) liefert ausschließlich Emissionen, die nach dem Start des Collectors eintreffen. Startest du deinen Event-Collector erst nach dem Aufruf von onLoginClicked, siehst du das Ereignis nicht – obwohl das ViewModel es korrekt gesendet hat. Die Lösung ist, den Collector immer vor der auslösenden Aktion zu starten, wie im Beispiel oben gezeigt. Alternativ kann replay=1 in bestimmten Szenarien sinnvoll sein, aber für Einmal-Navigationsevents ist das in der Regel unerwünscht, weil ein neu gestarteter Collector das abgelaufene Ereignis erneut empfangen würde.

Fazit

ViewModel-Tests sind das direkteste Mittel, um sicherzustellen, dass deine App auf jede Benutzeraktion korrekt reagiert – unabhängig von Gerät oder Android-Version. Schreibe nach jedem neuen Feature mindestens einen Test für den Erfolgspfad und einen für den Fehlerpfad. Füge anschließend absichtlich einen Bug ein, stelle sicher, dass der Test rot wird, und behebe den Fehler. Dieses Red-Green-Refactor-Muster festigt dein mentales Modell der Zustandslogik und gibt dir die Sicherheit, dass deine Tests tatsächlich schützen – und nicht nur grün leuchten.

Quellen (6)
Redaktion

Geschrieben von

Redaktion

Das Redaktionsteam recherchiert und schreibt Artikel zu aktuellen Themen rund um Tech, Lifestyle und Ratgeber.