Kotlin-Abläufe unter Android testen

Wie Sie Einheiten oder Module testen, die mit flow kommunizieren, hängt davon ab, ob das zu testende Subjekt den Flow als Eingabe oder Ausgabe verwendet.

  • Wenn das zu testende Subjekt einen Ablauf beobachtet, können Sie Abläufe innerhalb fiktiver Abhängigkeiten generieren, die Sie über Tests steuern können.
  • Wenn die Einheit oder das Modul einen Ablauf freigibt, können Sie ein oder mehrere Elemente lesen und prüfen, die von einem Ablauf im Test ausgegeben werden.

Erstellung eines gefälschten Produzenten

Wenn das zu testende Subjekt ein Nutzer eines Ablaufs ist, wird häufig der Producer durch eine gefälschte Implementierung ersetzt. Nehmen wir als Beispiel eine Klasse, die ein Repository beobachtet, das Daten aus zwei Datenquellen in der Produktion übernimmt:

das zu testende Objekt und die Datenschicht
Abbildung 1. Das zu testende Objekt und die Datenschicht.

Damit der Test deterministisch wird, können Sie das Repository und seine Abhängigkeiten durch ein fiktives Repository ersetzen, das immer die gleichen fiktiven Daten ausgibt:

Abhängigkeiten durch eine fiktive Implementierung ersetzt werden
Abbildung 2. Abhängigkeiten werden durch eine fiktive Implementierung ersetzt.

Verwenden Sie den flow-Builder, um eine vordefinierte Wertereihe in einem Ablauf auszugeben:

class MyFakeRepository : MyRepository {
    fun observeCount() = flow {
        emit(ITEM_1)
    }
}

Im Test wird dieses fiktive Repository eingeschleust, das die tatsächliche Implementierung ersetzt:

@Test
fun myTest() {
    // Given a class with fake dependencies:
    val sut = MyUnitUnderTest(MyFakeRepository())
    // Trigger and verify
    ...
}

Jetzt, da Sie die Kontrolle über die Ausgaben des zu testenden Themas haben, können Sie überprüfen, ob es korrekt funktioniert, indem Sie die Ausgaben überprüfen.

Flussemissionen in einem Test geltend machen

Wenn das zu testende Subjekt einen Fluss zur Verfügung stellt, muss der Test Assertions zu den Elementen des Datenstreams machen.

Angenommen, das Repository des vorherigen Beispiels gibt einen Ablauf frei:

Repository mit fiktiven Abhängigkeiten, das einen Ablauf freigibt
Abbildung 3: Ein Repository (das zu testende Objekt) mit unechten Abhängigkeiten, das einen Ablauf freigibt.

Bei bestimmten Tests musst du nur die erste Emissionen oder eine begrenzte Anzahl von Elementen, die aus dem Ablauf stammen, prüfen.

Die erste Emissionen des Flusses können Sie durch Aufrufen von first() verbrauchen. Diese Funktion wartet, bis das erste Element empfangen wird, und sendet dann das Abbruchsignal an den Ersteller.

@Test
fun myRepositoryTest() = runTest {
    // Given a repository that combines values from two data sources:
    val repository = MyRepository(fakeSource1, fakeSource2)

    // When the repository emits a value
    val firstItem = repository.counter.first() // Returns the first item in the flow

    // Then check it's the expected item
    assertEquals(ITEM_1, firstItem)
}

Wenn der Test mehrere Werte prüfen muss, führt der Aufruf von toList() dazu, dass der Fluss darauf wartet, dass die Quelle alle ihre Werte ausgibt, und gibt diese Werte dann als Liste zurück. Das funktioniert nur bei begrenzten Datenstreams.

@Test
fun myRepositoryTest() = runTest {
    // Given a repository with a fake data source that emits ALL_MESSAGES
    val messages = repository.observeChatMessages().toList()

    // When all messages are emitted then they should be ALL_MESSAGES
    assertEquals(ALL_MESSAGES, messages)
}

Bei Datenstreams, die eine komplexere Sammlung von Elementen erfordern oder nicht eine begrenzte Anzahl von Elementen zurückgeben, können Sie die Flow API zum Auswählen und Transformieren von Elementen verwenden. Hier einige Beispiele:

// Take the second item
outputFlow.drop(1).first()

// Take the first 5 items
outputFlow.take(5).toList()

// Takes the first item verifying that the flow is closed after that
outputFlow.single()

// Finite data streams
// Verify that the flow emits exactly N elements (optional predicate)
outputFlow.count()
outputFlow.count(predicate)

Kontinuierliche Erfassung während eines Tests

Beim Erfassen eines Datenflusses mit toList(), wie im vorherigen Beispiel gezeigt, wird collect() intern verwendet und der Vorgang wird angehalten, bis die gesamte Ergebnisliste zurückgegeben werden kann.

Wenn Sie Aktionen verschachteln möchten, die dazu führen, dass der Ablauf Werte und Assertions für die ausgegebenen Werte ausgibt, können Sie während eines Tests kontinuierlich Werte aus einem Ablauf erfassen.

Nehmen wir beispielsweise die folgende Repository-Klasse, die getestet werden soll, und eine zugehörige gefälschte Datenquellenimplementierung mit einer emit-Methode, um Werte während des Tests dynamisch zu erzeugen:

class Repository(private val dataSource: DataSource) {
    fun scores(): Flow<Int> {
        return dataSource.counts().map { it * 10 }
    }
}

class FakeDataSource : DataSource {
    private val flow = MutableSharedFlow<Int>()
    suspend fun emit(value: Int) = flow.emit(value)
    override fun counts(): Flow<Int> = flow
}

Wenn Sie diese Fälschung in einem Test verwenden, können Sie eine Erfassungskoroutine erstellen, die kontinuierlich die Werte von Repository empfängt. In diesem Beispiel werden sie in einer Liste gesammelt und dann Assertions für den Inhalt ausgeführt:

@Test
fun continuouslyCollect() = runTest {
    val dataSource = FakeDataSource()
    val repository = Repository(dataSource)

    val values = mutableListOf<Int>()
    backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
        repository.scores().toList(values)
    }

    dataSource.emit(1)
    assertEquals(10, values[0]) // Assert on the list contents

    dataSource.emit(2)
    dataSource.emit(3)
    assertEquals(30, values[2])

    assertEquals(3, values.size) // Assert the number of items collected
}

Da der von Repository hier bereitgestellte Ablauf nie abgeschlossen wird, wird der erfasste toList-Aufruf nie zurückgegeben. Wenn Sie die Erfassungskoroutine in TestScope.backgroundScope starten, wird sie vor dem Ende des Tests abgebrochen. Andernfalls würde runTest weiter auf den Abschluss warten, wodurch der Test nicht mehr reagiert und schließlich fehlschlägt.

Beachten Sie, wie hier UnconfinedTestDispatcher für die Erfassungskoroutine verwendet wird. Dadurch wird die Datenerfassungskoroutine gestartet und bereit, Werte zu empfangen, nachdem launch zurückgegeben wurde.

Mit Turbine

Die Turbine-Bibliothek eines Drittanbieters bietet eine praktische API zum Erstellen einer Erfassungskoroutine sowie weitere praktische Funktionen zum Testen von Abläufen:

@Test
fun usingTurbine() = runTest {
    val dataSource = FakeDataSource()
    val repository = Repository(dataSource)

    repository.scores().test {
        // Make calls that will trigger value changes only within test{}
        dataSource.emit(1)
        assertEquals(10, awaitItem())

        dataSource.emit(2)
        awaitItem() // Ignore items if needed, can also use skip(n)

        dataSource.emit(3)
        assertEquals(30, awaitItem())
    }
}

Weitere Informationen finden Sie in der Dokumentation der Bibliothek.

StateFlows testen

StateFlow ist ein beobachtbarer Datenspeicher, der erfasst werden kann, um die Werte im Zeitverlauf als Stream zu beobachten. Dieser Wertestream ist verdichtet. Wenn Werte in einer StateFlow schnell festgelegt werden, erhalten die Collectors dieser StateFlow nicht unbedingt alle Zwischenwerte, sondern nur den neuesten.

Wenn Sie die Zusammenführung beachten, können Sie in Tests die Werte einer StateFlow wie jeden anderen Fluss erfassen, auch mit Turbine. Der Versuch, alle Zwischenwerte zu erfassen und auf sie anzuwenden, kann in einigen Testszenarien wünschenswert sein.

Wir empfehlen jedoch im Allgemeinen, StateFlow als Dateninhaber zu behandeln und stattdessen auf dessen Attribut value zu erheben. Auf diese Weise validieren Tests den aktuellen Status des Objekts zu einem bestimmten Zeitpunkt und sind unabhängig davon, ob die Konfiguration erfolgt.

Nehmen wir beispielsweise diese ViewModel, die Werte aus einem Repository erfasst und sie der UI in einer StateFlow zur Verfügung stellt:

class MyViewModel(private val myRepository: MyRepository) : ViewModel() {
    private val _score = MutableStateFlow(0)
    val score: StateFlow<Int> = _score.asStateFlow()

    fun initialize() {
        viewModelScope.launch {
            myRepository.scores().collect { score ->
                _score.value = score
            }
        }
    }
}

Eine fiktive Implementierung für diese Repository könnte so aussehen:

class FakeRepository : MyRepository {
    private val flow = MutableSharedFlow<Int>()
    suspend fun emit(value: Int) = flow.emit(value)
    override fun scores(): Flow<Int> = flow
}

Wenn Sie das ViewModel mit dieser Fälschung testen, können Sie Werte aus der Fälschung ausgeben, um Aktualisierungen im StateFlow des ViewModel-Elements auszulösen, und dann die aktualisierte value durchsetzen:

@Test
fun testHotFakeRepository() = runTest {
    val fakeRepository = FakeRepository()
    val viewModel = MyViewModel(fakeRepository)

    assertEquals(0, viewModel.score.value) // Assert on the initial value

    // Start collecting values from the Repository
    viewModel.initialize()

    // Then we can send in values one by one, which the ViewModel will collect
    fakeRepository.emit(1)
    assertEquals(1, viewModel.score.value)

    fakeRepository.emit(2)
    fakeRepository.emit(3)
    assertEquals(3, viewModel.score.value) // Assert on the latest value
}

Mit StateFlows arbeiten, die von stateIn erstellt wurden

Im vorherigen Abschnitt verwendet ViewModel ein MutableStateFlow, um den neuesten Wert zu speichern, der von einem Ablauf von Repository ausgegeben wird. Dies ist ein gängiges Muster, das in der Regel einfacher implementiert wird, indem der Operator stateIn verwendet wird, der einen kalten Fluss in einen Hot-StateFlow umwandelt:

class MyViewModelWithStateIn(myRepository: MyRepository) : ViewModel() {
    val score: StateFlow<Int> = myRepository.scores()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), 0)
}

Der Operator stateIn hat einen SharingStarted-Parameter, der bestimmt, wann er aktiv wird und den zugrunde liegenden Ablauf verwendet. Optionen wie SharingStarted.Lazily und SharingStarted.WhileSubsribed werden häufig in ViewModels verwendet.

Auch wenn Sie in Ihrem Test den value von StateFlow bestätigen, müssen Sie einen Collector erstellen. Dies kann ein leerer Collector sein:

@Test
fun testLazilySharingViewModel() = runTest {
    val fakeRepository = HotFakeRepository()
    val viewModel = MyViewModelWithStateIn(fakeRepository)

    // Create an empty collector for the StateFlow
    backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
        viewModel.score.collect()
    }

    assertEquals(0, viewModel.score.value) // Can assert initial value

    // Trigger-assert like before
    fakeRepository.emit(1)
    assertEquals(1, viewModel.score.value)

    fakeRepository.emit(2)
    fakeRepository.emit(3)
    assertEquals(3, viewModel.score.value)
}

Weitere Informationen