Introduced the turbine library for efficiently testing the coroutine flows.

* Removed the `TestObserver` since it was sometimes stucks on the execution.
* Refactored the test cases according to turbine library.
This commit is contained in:
MohitMaliFtechiz 2025-05-09 21:39:47 +05:30
parent 2f9439391a
commit 4af14d8c98
10 changed files with 224 additions and 273 deletions

View File

@ -130,6 +130,7 @@ androidComponents {
dependencies {
androidTestImplementation(Libs.leakcanary_android_instrumentation)
testImplementation(Libs.kotlinx_coroutines_test)
testImplementation(Libs.TURBINE_FLOW_TEST)
}
tasks.register("generateVersionCodeAndName") {
val file = File("VERSION_INFO")

View File

@ -1,102 +0,0 @@
/*
* Kiwix Android
* Copyright (c) 2025 Kiwix <android.kiwix.org>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
package org.kiwix.kiwixmobile
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import org.assertj.core.api.Assertions.assertThat
fun <T> Flow<T>.test(scope: TestScope, itemCountsToEmitInFlow: Int = 1): TestObserver<T> =
TestObserver(scope, this, itemCountsToEmitInFlow).also { it.startCollecting() }
class TestObserver<T>(
private val scope: TestScope,
private val flow: Flow<T>,
private val itemCountsToEmitInFlow: Int
) {
private val values = mutableListOf<T>()
private val completionChannel = Channel<Unit>()
private var job: Job? = null
fun startCollecting() {
job = scope.launch {
flow.collect {
values.add(it)
completionChannel.send(Unit)
}
}
}
/**
* Returns the list of values collected from the flow.
*
* If [shouldAwaitCompletion] is true, this method will suspend until the flow
* signals completion, ensuring all values have been collected before returning.
*
* @param shouldAwaitCompletion Whether to wait for the flow to finish collecting before returning the results.
* @return A mutable list of values emitted by the flow.
*/
suspend fun getValues(shouldAwaitCompletion: Boolean = true): MutableList<T> {
if (shouldAwaitCompletion) {
awaitCompletion()
}
return values
}
private suspend fun awaitCompletion() {
repeat(itemCountsToEmitInFlow) {
completionChannel.receive()
}
}
suspend fun assertValues(listValues: MutableList<T>): TestObserver<T> {
awaitCompletion()
assertThat(listValues).containsExactlyElementsOf(values)
return this
}
suspend fun containsExactlyInAnyOrder(
listValues: MutableList<T>,
vararg values: T
): TestObserver<T> {
awaitCompletion()
assertThat(listValues).containsExactlyInAnyOrder(*values)
return this
}
suspend fun assertLastValue(value: T): TestObserver<T> {
awaitCompletion()
assertThat(values.last()).isEqualTo(value)
return this
}
suspend fun finish() {
job?.cancelAndJoin()
}
suspend fun assertLastValue(value: (T) -> Boolean): TestObserver<T> {
awaitCompletion()
assertThat(values.last()).satisfies({ value(it) })
return this
}
}

View File

@ -23,6 +23,8 @@ import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.NetworkCapabilities.TRANSPORT_WIFI
import android.os.Build
import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test
import com.jraska.livedata.test
import io.mockk.clearAllMocks
import io.mockk.every
@ -34,9 +36,13 @@ import io.reactivex.processors.PublishProcessor
import io.reactivex.schedulers.TestScheduler
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Disabled
@ -65,7 +71,6 @@ import org.kiwix.kiwixmobile.core.zim_manager.fileselect_view.BooksOnDiskListIte
import org.kiwix.kiwixmobile.core.zim_manager.fileselect_view.BooksOnDiskListItem.BookOnDisk
import org.kiwix.kiwixmobile.core.zim_manager.fileselect_view.SelectionMode.MULTI
import org.kiwix.kiwixmobile.core.zim_manager.fileselect_view.SelectionMode.NORMAL
import org.kiwix.kiwixmobile.test
import org.kiwix.kiwixmobile.zimManager.Fat32Checker.FileSystemState
import org.kiwix.kiwixmobile.zimManager.Fat32Checker.FileSystemState.CanWrite4GbFile
import org.kiwix.kiwixmobile.zimManager.Fat32Checker.FileSystemState.CannotWrite4GbFile
@ -213,7 +218,7 @@ class ZimManageViewModelTest {
inner class Books {
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `emissions from dat source are observed`() {
fun `emissions from data source are observed`() {
val expectedList = listOf(bookOnDisk())
booksOnDiskListItems.onNext(expectedList)
testScheduler.triggerActions()
@ -486,10 +491,11 @@ class ZimManageViewModelTest {
),
NORMAL
)
viewModel.sideEffects.test(this)
.also { viewModel.fileSelectActions.emit(RequestMultiSelection(bookToSelect)) }
.assertValues(mutableListOf(StartMultiSelection(viewModel.fileSelectActions)))
.finish()
testFlow(
flow = viewModel.sideEffects,
triggerAction = { viewModel.fileSelectActions.emit(RequestMultiSelection(bookToSelect)) },
assert = { assertThat(awaitItem()).isEqualTo(StartMultiSelection(viewModel.fileSelectActions)) }
)
viewModel.fileSelectListStates.test()
.assertValue(
FileSelectListState(
@ -504,10 +510,18 @@ class ZimManageViewModelTest {
val selectedBook = bookOnDisk().apply { isSelected = true }
viewModel.fileSelectListStates.value =
FileSelectListState(listOf(selectedBook, bookOnDisk()), NORMAL)
viewModel.sideEffects.test(this)
.also { viewModel.fileSelectActions.emit(RequestDeleteMultiSelection) }
.assertValues(mutableListOf(DeleteFiles(listOf(selectedBook), alertDialogShower)))
.finish()
testFlow(
flow = viewModel.sideEffects,
triggerAction = { viewModel.fileSelectActions.emit(RequestDeleteMultiSelection) },
assert = {
assertThat(awaitItem()).isEqualTo(
DeleteFiles(
listOf(selectedBook),
alertDialogShower
)
)
}
)
}
@Test
@ -515,10 +529,11 @@ class ZimManageViewModelTest {
val selectedBook = bookOnDisk().apply { isSelected = true }
viewModel.fileSelectListStates.value =
FileSelectListState(listOf(selectedBook, bookOnDisk()), NORMAL)
viewModel.sideEffects.test(this)
.also { viewModel.fileSelectActions.emit(RequestShareMultiSelection) }
.assertValues(mutableListOf(ShareFiles(listOf(selectedBook))))
.finish()
testFlow(
flow = viewModel.sideEffects,
triggerAction = { viewModel.fileSelectActions.emit(RequestShareMultiSelection) },
assert = { assertThat(awaitItem()).isEqualTo(ShareFiles(listOf(selectedBook))) }
)
}
@Test
@ -526,10 +541,11 @@ class ZimManageViewModelTest {
val selectedBook = bookOnDisk().apply { isSelected = true }
viewModel.fileSelectListStates.value =
FileSelectListState(listOf(selectedBook, bookOnDisk()), NORMAL)
viewModel.sideEffects.test(this)
.also { viewModel.fileSelectActions.emit(MultiModeFinished) }
.assertValues(mutableListOf(None))
.finish()
testFlow(
flow = viewModel.sideEffects,
triggerAction = { viewModel.fileSelectActions.emit(MultiModeFinished) },
assert = { assertThat(awaitItem()).isEqualTo(None) }
)
viewModel.fileSelectListStates.test().assertValue(
FileSelectListState(
listOf(
@ -545,10 +561,11 @@ class ZimManageViewModelTest {
val selectedBook = bookOnDisk(0L).apply { isSelected = true }
viewModel.fileSelectListStates.value =
FileSelectListState(listOf(selectedBook, bookOnDisk(1L)), NORMAL)
viewModel.sideEffects.test(this)
.also { viewModel.fileSelectActions.emit(RequestSelect(selectedBook)) }
.assertValues(mutableListOf(None))
.finish()
testFlow(
flow = viewModel.sideEffects,
triggerAction = { viewModel.fileSelectActions.emit(RequestSelect(selectedBook)) },
assert = { assertThat(awaitItem()).isEqualTo(None) }
)
viewModel.fileSelectListStates.test().assertValue(
FileSelectListState(
listOf(
@ -561,10 +578,26 @@ class ZimManageViewModelTest {
@Test
fun `RestartActionMode offers StartMultiSelection`() = runTest {
viewModel.sideEffects.test(this)
.also { viewModel.fileSelectActions.emit(RestartActionMode) }
.assertValues(mutableListOf(StartMultiSelection(viewModel.fileSelectActions)))
.finish()
testFlow(
flow = viewModel.sideEffects,
triggerAction = { viewModel.fileSelectActions.emit(RestartActionMode) },
assert = { assertThat(awaitItem()).isEqualTo(StartMultiSelection(viewModel.fileSelectActions)) }
)
}
}
}
suspend fun <T> TestScope.testFlow(
flow: Flow<T>,
triggerAction: suspend () -> Unit,
assert: suspend TurbineTestContext<T>.() -> Unit
) {
val job = launch {
flow.test {
triggerAction()
assert()
cancelAndIgnoreRemainingEvents()
}
}
job.join()
}

View File

@ -29,6 +29,11 @@ object Libs {
const val kotlinx_coroutines_test: String = "org.jetbrains.kotlinx:kotlinx-coroutines-test:" +
Versions.org_jetbrains_kotlinx_kotlinx_coroutines
/**
* https://github.com/cashapp/turbine
*/
const val TURBINE_FLOW_TEST: String = "app.cash.turbine:turbine:" + Versions.TURBINE_FLOW_TEST
/**
* https://developer.android.com/testing
*/

View File

@ -119,6 +119,8 @@ object Versions {
const val COMPOSE_VERSION = "1.7.8"
const val COMPOSE_MATERIAL3 = "1.3.1"
const val TURBINE_FLOW_TEST = "1.2.0"
}
/**

View File

@ -63,4 +63,5 @@ dependencies {
testImplementation(Libs.kotlinx_coroutines_test)
implementation(Libs.kotlinx_coroutines_android)
implementation(Libs.zxing)
testImplementation(Libs.TURBINE_FLOW_TEST)
}

View File

@ -26,8 +26,8 @@ import io.reactivex.schedulers.Schedulers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@ -37,10 +37,11 @@ import org.kiwix.kiwixmobile.core.downloader.model.DownloadModel
import org.kiwix.kiwixmobile.core.reader.ZimFileReader
import org.kiwix.kiwixmobile.core.reader.ZimFileReader.Factory
import org.kiwix.kiwixmobile.core.reader.ZimReaderSource
import org.kiwix.kiwixmobile.core.search.viewmodel.test
import org.kiwix.kiwixmobile.core.utils.SharedPreferenceUtil
import org.kiwix.kiwixmobile.core.utils.files.FileSearch
import org.kiwix.kiwixmobile.core.utils.files.ScanningProgressListener
import org.kiwix.kiwixmobile.core.utils.files.testFlow
import org.kiwix.kiwixmobile.core.zim_manager.fileselect_view.BooksOnDiskListItem.BookOnDisk
import org.kiwix.sharedFunctions.book
import org.kiwix.sharedFunctions.bookOnDisk
import org.kiwix.sharedFunctions.resetSchedulers
@ -86,7 +87,11 @@ class StorageObserverTest {
@Test
fun `books from disk are filtered by current downloads`() = runTest {
withFiltering()
booksOnFileSystem(this).assertValues(mutableListOf(mutableListOf())).finish()
testFlow(
flow = booksOnFileSystem(),
triggerAction = {},
assert = { assertThat(awaitItem()).isEqualTo(listOf<BookOnDisk>()) }
)
}
@OptIn(ExperimentalCoroutinesApi::class)
@ -100,23 +105,25 @@ class StorageObserverTest {
withNoFiltering()
every { zimFileReader.toBook() } returns expectedBook
every { zimFileReader.zimReaderSource } returns zimReaderSource
val testObserver = booksOnFileSystem(this)
testObserver.assertValues(
mutableListOf(
mutableListOf(
bookOnDisk(
book = expectedBook,
zimReaderSource = zimReaderSource
testFlow(
flow = booksOnFileSystem(),
triggerAction = {},
assert = {
assertThat(awaitItem()).isEqualTo(
listOf<BookOnDisk>(
bookOnDisk(
book = expectedBook,
zimReaderSource = zimReaderSource
)
)
)
)
).finish()
}
)
verify { zimFileReader.dispose() }
}
private fun booksOnFileSystem(testScope: TestScope) =
private fun booksOnFileSystem() =
storageObserver.getBooksOnFileSystem(scanningProgressListener)
.test(testScope)
.also {
downloads.value = listOf(downloadModel)
files.value = listOf(file)

View File

@ -23,6 +23,8 @@ import android.content.Context
import android.database.Cursor
import android.os.Environment
import android.provider.MediaStore.MediaColumns
import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test
import eu.mhutti1.utils.storage.StorageDevice
import eu.mhutti1.utils.storage.StorageDeviceUtils
import io.mockk.clearMocks
@ -30,12 +32,15 @@ import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.reactivex.schedulers.Schedulers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.kiwix.kiwixmobile.core.search.viewmodel.test
import org.kiwix.sharedFunctions.resetSchedulers
import org.kiwix.sharedFunctions.setScheduler
import java.io.File
@ -81,10 +86,11 @@ class FileSearchTest {
@Test
fun `scan of directory that doesn't exist returns nothing`() = runTest {
every { contentResolver.query(any(), any(), any(), any(), any()) } returns null
fileSearch.scan(scanningProgressListener)
.test(this)
.assertValues(mutableListOf(mutableListOf()))
.finish()
testFlow(
flow = fileSearch.scan(scanningProgressListener),
triggerAction = {},
assert = { assertThat(awaitItem()).isEqualTo(emptyList<File>()) }
)
}
@Test
@ -94,13 +100,11 @@ class FileSearchTest {
File.createTempFile("willNotFind", ".txt")
every { contentResolver.query(any(), any(), any(), any(), any()) } returns null
every { storageDevice.name } returns zimFile.parent
val testObserver = fileSearch.scan(scanningProgressListener)
.test(this)
val observedValues = testObserver.getValues().first()
testObserver.containsExactlyInAnyOrder(
mutableListOf(observedValues),
listOf(zimFile, zimaaFile)
).finish()
testFlow(
flow = fileSearch.scan(scanningProgressListener),
triggerAction = {},
assert = { assertThat(awaitItem()).containsExactlyInAnyOrder(zimFile, zimaaFile) }
)
}
@Test
@ -116,13 +120,11 @@ class FileSearchTest {
)
every { contentResolver.query(any(), any(), any(), any(), any()) } returns null
every { storageDevice.name } returns zimFile.parentFile.parent
val testObserver = fileSearch.scan(scanningProgressListener)
.test(this)
val observedValue = testObserver.getValues()[0]
testObserver.containsExactlyInAnyOrder(
mutableListOf(observedValue),
listOf(zimFile)
).finish()
testFlow(
flow = fileSearch.scan(scanningProgressListener),
triggerAction = {},
assert = { assertThat(awaitItem()[0]).isEqualTo(zimFile) }
)
}
}
@ -132,10 +134,11 @@ class FileSearchTest {
fun `scan media store, if files are readable they are returned`() = runTest {
val fileToFind = File.createTempFile("fileToFind", ".zim")
expectFromMediaStore(fileToFind)
fileSearch.scan(scanningProgressListener)
.test(this)
.assertValues(mutableListOf(listOf(fileToFind)))
.finish()
testFlow(
flow = fileSearch.scan(scanningProgressListener),
triggerAction = {},
assert = { assertThat(awaitItem()).isEqualTo(listOf(fileToFind)) }
)
}
@Test
@ -143,10 +146,11 @@ class FileSearchTest {
val unreadableFile = File.createTempFile("fileToFind", ".zim")
expectFromMediaStore(unreadableFile)
unreadableFile.delete()
fileSearch.scan(scanningProgressListener)
.test(this)
.assertValues(mutableListOf(mutableListOf()))
.finish()
testFlow(
flow = fileSearch.scan(scanningProgressListener),
triggerAction = {},
assert = { assertThat(awaitItem()).isEqualTo(listOf<File>()) }
)
}
private fun expectFromMediaStore(fileToFind: File) {
@ -168,7 +172,26 @@ class FileSearchTest {
}
private fun deleteTempDirectory() {
File.createTempFile("temp", ".txt")
.parentFile.deleteRecursively()
try {
File.createTempFile("temp", ".txt")
.parentFile.deleteRecursively()
} catch (ignore: Exception) {
ignore.printStackTrace()
}
}
}
suspend fun <T> TestScope.testFlow(
flow: Flow<T>,
triggerAction: suspend () -> Unit,
assert: suspend TurbineTestContext<T>.() -> Unit
) {
val job = launch {
flow.test {
triggerAction()
assert()
cancelAndIgnoreRemainingEvents()
}
}
job.join()
}

View File

@ -62,6 +62,7 @@ android {
dependencies {
testImplementation(Libs.kotlinx_coroutines_test)
testImplementation(Libs.TURBINE_FLOW_TEST)
}
fun ProductFlavor.createDownloadTask(file: File): TaskProvider<Task> {

View File

@ -18,23 +18,19 @@
package org.kiwix.kiwixmobile.custom.download
import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test
import com.tonyodev.fetch2.Error.NONE
import io.mockk.clearAllMocks
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
@ -79,16 +75,20 @@ internal class CustomDownloadViewModelTest {
@Test
internal fun `effects emits SetPreferred on Subscribe`() = runTest {
customDownloadViewModel.effects.test(this)
.assertValues(mutableListOf(setPreferredStorageWithMostSpace))
.finish()
testFlow(
flow = customDownloadViewModel.effects,
triggerAction = {},
assert = { assertThat(awaitItem()).isEqualTo(setPreferredStorageWithMostSpace) }
)
}
@Test
internal fun `initial State is DownloadRequired`() = runTest {
customDownloadViewModel.state.test(this)
.assertValues(mutableListOf(State.DownloadRequired))
.finish()
testFlow(
flow = customDownloadViewModel.state,
triggerAction = {},
assert = { assertThat(awaitItem()).isEqualTo(State.DownloadRequired) }
)
}
@Nested
@ -97,26 +97,26 @@ internal class CustomDownloadViewModelTest {
internal fun `Emission with data moves state from Required to InProgress`() = runTest {
assertStateTransition(
this,
1,
DownloadRequired,
DatabaseEmission(listOf(downloadItem())),
State.DownloadInProgress(listOf(downloadItem()))
State.DownloadInProgress(listOf(downloadItem())),
2
)
}
@Test
internal fun `Emission without data moves state from Required to Required`() = runTest {
assertStateTransition(this, 1, DownloadRequired, DatabaseEmission(listOf()), DownloadRequired)
assertStateTransition(this, DownloadRequired, DatabaseEmission(listOf()), DownloadRequired)
}
@Disabled
@Test
internal fun `Emission with data moves state from Failed to InProgress`() = runTest {
assertStateTransition(
this,
1,
DownloadFailed(DownloadState.Pending),
DatabaseEmission(listOf(downloadItem())),
State.DownloadInProgress(listOf(downloadItem()))
State.DownloadInProgress(listOf(downloadItem())),
2
)
}
@ -124,7 +124,6 @@ internal class CustomDownloadViewModelTest {
internal fun `Emission without data moves state from Failed to Failed`() = runTest {
assertStateTransition(
this,
1,
DownloadFailed(DownloadState.Pending),
DatabaseEmission(listOf()),
DownloadFailed(DownloadState.Pending)
@ -135,10 +134,10 @@ internal class CustomDownloadViewModelTest {
internal fun `Emission with data+failure moves state from InProgress to Failed`() = runTest {
assertStateTransition(
this,
1,
DownloadInProgress(listOf()),
DatabaseEmission(listOf(downloadItem(state = Failed(NONE, null)))),
DownloadFailed(Failed(NONE, null))
DownloadFailed(Failed(NONE, null)),
2
)
}
@ -146,117 +145,98 @@ internal class CustomDownloadViewModelTest {
internal fun `Emission with data moves state from InProgress to InProgress`() = runTest {
assertStateTransition(
this,
1,
DownloadInProgress(listOf(downloadItem(downloadId = 1L))),
DatabaseEmission(listOf(downloadItem(downloadId = 2L))),
DownloadInProgress(listOf(downloadItem(downloadId = 2L)))
DownloadInProgress(listOf(downloadItem(downloadId = 2L))),
2
)
}
@Disabled("TODO fix in upcoming issue when properly migrated to coroutines")
@Test
internal fun `Emission without data moves state from InProgress to Complete`() = runTest {
val sideEffects = customDownloadViewModel.effects.test(this)
assertStateTransition(
this,
1,
DownloadInProgress(listOf()),
DatabaseEmission(listOf()),
DownloadComplete
testFlow(
flow = customDownloadViewModel.effects,
triggerAction = {
assertStateTransition(
this,
DownloadInProgress(listOf()),
DatabaseEmission(listOf()),
DownloadComplete,
2
)
},
assert = {
assertThat(awaitItem()).isEqualTo(setPreferredStorageWithMostSpace)
assertThat(awaitItem()).isEqualTo(navigateToCustomReader)
}
)
sideEffects.assertValues(
mutableListOf(
setPreferredStorageWithMostSpace,
navigateToCustomReader
)
).finish()
}
@Test
internal fun `Any emission does not change state from Complete`() = runTest {
assertStateTransition(
this,
1,
DownloadComplete,
DatabaseEmission(listOf(downloadItem())),
DownloadComplete
)
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun assertStateTransition(
private suspend fun assertStateTransition(
testScope: TestScope,
flowCount: Int,
initialState: State,
action: DatabaseEmission,
endState: State
endState: State,
awaitItemCount: Int = 1
) {
customDownloadViewModel.getStateForTesting().value = initialState
testScope.launch {
customDownloadViewModel.actions.emit(action)
testScope.advanceUntilIdle()
customDownloadViewModel.state.test(testScope, flowCount)
.assertLastValues(mutableListOf(endState)).finish()
}
testScope.testFlow(
flow = customDownloadViewModel.state,
triggerAction = { customDownloadViewModel.actions.emit(action) },
assert = {
val items = (1..awaitItemCount).map { awaitItem() }
assertThat(items.last()).isEqualTo(endState)
}
)
}
}
@Disabled("TODO fix in upcoming issue when properly migrated to coroutines")
@Test
internal fun `clicking Retry triggers DownloadCustom`() = runTest {
val sideEffects = customDownloadViewModel.effects.test(this)
customDownloadViewModel.actions.emit(ClickedRetry)
sideEffects.assertValues(mutableListOf(setPreferredStorageWithMostSpace, downloadCustom))
.finish()
}
@Disabled("TODO fix in upcoming issue when properly migrated to coroutines")
internal fun `clicking Download triggers DownloadCustom`() = runTest {
val sideEffects = customDownloadViewModel.effects.test(this)
customDownloadViewModel.actions.emit(ClickedDownload)
sideEffects.assertValues(mutableListOf(setPreferredStorageWithMostSpace, downloadCustom))
.finish()
}
}
fun <T> Flow<T>.test(scope: TestScope, itemCountsToEmitInFlow: Int = 1): TestObserver<T> =
TestObserver(scope, this, itemCountsToEmitInFlow).also { it.startCollecting() }
class TestObserver<T>(
private val scope: TestScope,
private val flow: Flow<T>,
private val itemCountsToEmitInFlow: Int
) {
private val values = mutableListOf<T>()
private val completionChannel = Channel<Unit>()
private var job: Job? = null
fun startCollecting() {
job = scope.launch {
flow.collect {
values.add(it)
completionChannel.send(Unit)
testFlow(
flow = customDownloadViewModel.effects,
triggerAction = { customDownloadViewModel.actions.emit(ClickedRetry) },
assert = {
assertThat(awaitItem()).isEqualTo(setPreferredStorageWithMostSpace)
assertThat(awaitItem()).isEqualTo(downloadCustom)
}
}
)
}
private suspend fun awaitCompletion() {
repeat(itemCountsToEmitInFlow) {
completionChannel.receive()
}
}
suspend fun assertValues(listValues: MutableList<T>): TestObserver<T> {
awaitCompletion()
assertThat(listValues).containsExactlyElementsOf(values)
return this
}
suspend fun assertLastValues(listValues: MutableList<T>): TestObserver<T> {
awaitCompletion()
assertThat(listValues).containsExactlyElementsOf(mutableListOf(values.last()))
return this
}
suspend fun finish() {
job?.cancelAndJoin()
@Test
internal fun `clicking Download triggers DownloadCustom`() = runTest {
testFlow(
flow = customDownloadViewModel.effects,
triggerAction = { customDownloadViewModel.actions.emit(ClickedDownload) },
assert = {
assertThat(awaitItem()).isEqualTo(setPreferredStorageWithMostSpace)
assertThat(awaitItem()).isEqualTo(downloadCustom)
}
)
}
}
suspend fun <T> TestScope.testFlow(
flow: Flow<T>,
triggerAction: suspend () -> Unit,
assert: suspend TurbineTestContext<T>.() -> Unit
) {
val job = launch {
flow.test {
triggerAction()
assert()
cancelAndIgnoreRemainingEvents()
}
}
job.join()
}