Kotlin-Abläufe unter Android testen

Die Art und Weise, wie Sie Einheiten oder Module testen, die mit flow kommunizieren, hängt davon ab, ob das Testobjekt den Ablauf als Eingabe oder Ausgabe verwendet.

  • Wenn die Testperson einen Ablauf beobachtet, können Sie Abläufe innerhalb von gefälschten Abhängigkeiten generieren, die Sie über Tests steuern können.
  • Wenn die Einheit oder das Modul einen Ablauf bereitstellt, können Sie ein oder mehrere Elemente lesen und überprüfen, die von einem Ablauf im Test gesendet wurden.

Erstellen eines gefälschten Erstellers

Wenn das Testobjekt ein Verbraucher eines Ablaufs ist, wird es häufig durch Ersetzen des Erzeugers durch eine gefälschte Implementierung getestet. Angenommen, es gibt eine Klasse, die ein Repository beobachtet, das Daten aus zwei Datenquellen in der Produktion bezieht:

das Testobjekt und die Datenebene
Abbildung 1 Das Testobjekt und die Datenebene.

Um den Test deterministisch zu gestalten, können Sie das Repository und seine Abhängigkeiten durch ein gefälschtes Repository ersetzen, das immer dieselben gefälschten Daten ausgibt:

Abhängigkeiten werden durch eine gefälschte Implementierung ersetzt
Abbildung 2: Abhängigkeiten werden durch eine gefälschte Implementierung ersetzt.

Wenn Sie eine vordefinierte Reihe von Werten in einem Ablauf senden möchten, verwenden Sie den flow-Builder:

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

Im Test wird dieses gefälschte Repository eingefügt und ersetzt die echte Implementierung:

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

Da Sie jetzt die Kontrolle über die Ausgaben des Testobjekts haben, können Sie prüfen, ob es richtig funktioniert.

Flussemissionen in einem Test prüfen

Wenn das Testobjekt einen Datenfluss sendet, müssen im Test Behauptungen zu den Elementen des Datenstreams aufgestellt werden.

Angenommen, das Repository des vorherigen Beispiels stellt einen Ablauf bereit:

Repository mit gefälschten Abhängigkeiten, die einen Ablauf offenlegen
Abbildung 3: Ein Repository (das Testobjekt) mit gefälschten Abhängigkeiten, die einen Ablauf offenlegen.

Bei bestimmten Tests müssen Sie nur die erste Auslieferung oder eine bestimmte Anzahl von Elementen aus dem Datenfluss prüfen.

Du kannst die erste Emission für den Ablauf verbrauchen, indem du first() aufrufst. Diese Funktion wartet, bis der erste Artikel empfangen wurde, und sendet dann das Stornierungssignal an den Produzenten.

@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 im Test mehrere Werte geprüft werden müssen, wartet der Ablauf beim Aufrufen von toList(), bis die Quelle alle Werte ausgegeben hat, und gibt diese dann als Liste zurück. Dies funktioniert nur für endliche 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, für die eine komplexere Sammlung von Elementen erforderlich ist oder die keine endliche Anzahl von Elementen zurückgeben, können Sie die Flow API verwenden, um Elemente auszuwählen und zu transformieren. 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

Wenn Sie einen Fluss wie im vorherigen Beispiel mit toList() erfassen, wird collect() intern verwendet und der Vorgang wird pausiert, bis die gesamte Ergebnisliste zurückgegeben werden kann.

Wenn Sie Aktionen einfügen möchten, die dazu führen, dass der Ablauf Werte ausgibt, und Behauptungen zu den ausgegeben Werten, können Sie während eines Tests kontinuierlich Werte aus einem Ablauf erfassen.

Nehmen wir als Beispiel die folgende zu testende Repository-Klasse und eine zugehörige Implementierung einer gefälschten Datenquelle mit einer emit-Methode, um während des Tests dynamisch Werte zu generieren:

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 diesen Fake in einem Test verwenden, können Sie eine erfassende Coroutine erstellen, die kontinuierlich die Werte aus der Repository empfängt. In diesem Beispiel werden sie in einer Liste gesammelt und dann werden Behauptungen über den Inhalt aufgestellt:

@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 durch Repository freigegebene Fluss hier nie abgeschlossen wird, wird der toList-Aufruf, der ihn erfasst, nie zurückgegeben. Wenn Sie die erfassende Coroutine in TestScope.backgroundScope starten, wird sie vor dem Ende des Tests abgebrochen. Andernfalls würde runTest weiter auf den Abschluss warten, was dazu führen würde, dass der Test nicht mehr reagiert und schließlich fehlschlägt.

Beachten Sie, dass hier UnconfinedTestDispatcher für die erfassende Coroutine verwendet wird. So wird sichergestellt, dass die erfassende Coroutine sofort gestartet wird und nach der Rückgabe von launch bereit ist, Werte zu empfangen.

Turbine verwenden

Die Drittanbieterbibliothek Turbine bietet eine praktische API zum Erstellen einer sammelnden Coroutine 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 Datenhalter, der erfasst werden kann, um die Werte im Zeitverlauf als Stream zu beobachten. Dieser Wertestream wird zusammengeführt. Wenn also Werte in einem StateFlow schnell festgelegt werden, erhalten die Abholer dieses StateFlow nicht garantiert alle Zwischenwerte, sondern nur den jeweils neuesten.

Wenn Sie bei Tests die Zusammenführung berücksichtigen, können Sie die Werte von StateFlow wie bei jedem anderen Fluss erfassen, auch mit Turbine. In einigen Testszenarien kann es sinnvoll sein, alle Zwischenwerte zu erfassen und zu prüfen.

Wir empfehlen jedoch generell, StateFlow als Dateninhaber zu behandeln und stattdessen einen Anspruch auf seine value-Property geltend zu machen. So wird der aktuelle Status des Objekts zu einem bestimmten Zeitpunkt geprüft und die Tests sind unabhängig davon, ob eine Zusammenführung stattfindet oder nicht.

Hier ist beispielsweise eine ViewModel zu sehen, die Werte aus einer Repository erfasst und in einer StateFlow in der Benutzeroberfläche anzeigt:

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 gefälschte 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 die ViewModel mit diesem Fake testen, können Sie Werte aus dem Fake ausgeben, um Aktualisierungen im StateFlow der ViewModel auszulösen, und dann eine Behauptung für die aktualisierte value aufstellen:

@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 mit „stateIn“ erstellt wurden

Im vorherigen Abschnitt wird in der ViewModel ein MutableStateFlow verwendet, um den letzten Wert zu speichern, der von einem Fluss aus der Repository gesendet wurde. Dies ist ein gängiges Muster, das in der Regel einfacher mit dem Operator stateIn implementiert wird, der einen kalten Fluss in einen heißen StateFlow umwandelt:

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

Der Operator stateIn hat einen Parameter SharingStarted, der festlegt, wann er aktiv wird und mit der Verarbeitung des zugrunde liegenden Ablaufs beginnt. Optionen wie SharingStarted.Lazily und SharingStarted.WhileSubscribed werden häufig in Ansichtsmodellen verwendet.

Auch wenn Sie in Ihrem Test eine Assertion für die value der StateFlow ausführen, 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