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 { dependencies {
androidTestImplementation(Libs.leakcanary_android_instrumentation) androidTestImplementation(Libs.leakcanary_android_instrumentation)
testImplementation(Libs.kotlinx_coroutines_test) testImplementation(Libs.kotlinx_coroutines_test)
testImplementation(Libs.TURBINE_FLOW_TEST)
} }
tasks.register("generateVersionCodeAndName") { tasks.register("generateVersionCodeAndName") {
val file = File("VERSION_INFO") 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
import android.net.NetworkCapabilities.TRANSPORT_WIFI import android.net.NetworkCapabilities.TRANSPORT_WIFI
import android.os.Build import android.os.Build
import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test
import com.jraska.livedata.test import com.jraska.livedata.test
import io.mockk.clearAllMocks import io.mockk.clearAllMocks
import io.mockk.every import io.mockk.every
@ -34,9 +36,13 @@ import io.reactivex.processors.PublishProcessor
import io.reactivex.schedulers.TestScheduler import io.reactivex.schedulers.TestScheduler
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Disabled 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.BooksOnDiskListItem.BookOnDisk
import org.kiwix.kiwixmobile.core.zim_manager.fileselect_view.SelectionMode.MULTI 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.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
import org.kiwix.kiwixmobile.zimManager.Fat32Checker.FileSystemState.CanWrite4GbFile import org.kiwix.kiwixmobile.zimManager.Fat32Checker.FileSystemState.CanWrite4GbFile
import org.kiwix.kiwixmobile.zimManager.Fat32Checker.FileSystemState.CannotWrite4GbFile import org.kiwix.kiwixmobile.zimManager.Fat32Checker.FileSystemState.CannotWrite4GbFile
@ -213,7 +218,7 @@ class ZimManageViewModelTest {
inner class Books { inner class Books {
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun `emissions from dat source are observed`() { fun `emissions from data source are observed`() {
val expectedList = listOf(bookOnDisk()) val expectedList = listOf(bookOnDisk())
booksOnDiskListItems.onNext(expectedList) booksOnDiskListItems.onNext(expectedList)
testScheduler.triggerActions() testScheduler.triggerActions()
@ -486,10 +491,11 @@ class ZimManageViewModelTest {
), ),
NORMAL NORMAL
) )
viewModel.sideEffects.test(this) testFlow(
.also { viewModel.fileSelectActions.emit(RequestMultiSelection(bookToSelect)) } flow = viewModel.sideEffects,
.assertValues(mutableListOf(StartMultiSelection(viewModel.fileSelectActions))) triggerAction = { viewModel.fileSelectActions.emit(RequestMultiSelection(bookToSelect)) },
.finish() assert = { assertThat(awaitItem()).isEqualTo(StartMultiSelection(viewModel.fileSelectActions)) }
)
viewModel.fileSelectListStates.test() viewModel.fileSelectListStates.test()
.assertValue( .assertValue(
FileSelectListState( FileSelectListState(
@ -504,10 +510,18 @@ class ZimManageViewModelTest {
val selectedBook = bookOnDisk().apply { isSelected = true } val selectedBook = bookOnDisk().apply { isSelected = true }
viewModel.fileSelectListStates.value = viewModel.fileSelectListStates.value =
FileSelectListState(listOf(selectedBook, bookOnDisk()), NORMAL) FileSelectListState(listOf(selectedBook, bookOnDisk()), NORMAL)
viewModel.sideEffects.test(this) testFlow(
.also { viewModel.fileSelectActions.emit(RequestDeleteMultiSelection) } flow = viewModel.sideEffects,
.assertValues(mutableListOf(DeleteFiles(listOf(selectedBook), alertDialogShower))) triggerAction = { viewModel.fileSelectActions.emit(RequestDeleteMultiSelection) },
.finish() assert = {
assertThat(awaitItem()).isEqualTo(
DeleteFiles(
listOf(selectedBook),
alertDialogShower
)
)
}
)
} }
@Test @Test
@ -515,10 +529,11 @@ class ZimManageViewModelTest {
val selectedBook = bookOnDisk().apply { isSelected = true } val selectedBook = bookOnDisk().apply { isSelected = true }
viewModel.fileSelectListStates.value = viewModel.fileSelectListStates.value =
FileSelectListState(listOf(selectedBook, bookOnDisk()), NORMAL) FileSelectListState(listOf(selectedBook, bookOnDisk()), NORMAL)
viewModel.sideEffects.test(this) testFlow(
.also { viewModel.fileSelectActions.emit(RequestShareMultiSelection) } flow = viewModel.sideEffects,
.assertValues(mutableListOf(ShareFiles(listOf(selectedBook)))) triggerAction = { viewModel.fileSelectActions.emit(RequestShareMultiSelection) },
.finish() assert = { assertThat(awaitItem()).isEqualTo(ShareFiles(listOf(selectedBook))) }
)
} }
@Test @Test
@ -526,10 +541,11 @@ class ZimManageViewModelTest {
val selectedBook = bookOnDisk().apply { isSelected = true } val selectedBook = bookOnDisk().apply { isSelected = true }
viewModel.fileSelectListStates.value = viewModel.fileSelectListStates.value =
FileSelectListState(listOf(selectedBook, bookOnDisk()), NORMAL) FileSelectListState(listOf(selectedBook, bookOnDisk()), NORMAL)
viewModel.sideEffects.test(this) testFlow(
.also { viewModel.fileSelectActions.emit(MultiModeFinished) } flow = viewModel.sideEffects,
.assertValues(mutableListOf(None)) triggerAction = { viewModel.fileSelectActions.emit(MultiModeFinished) },
.finish() assert = { assertThat(awaitItem()).isEqualTo(None) }
)
viewModel.fileSelectListStates.test().assertValue( viewModel.fileSelectListStates.test().assertValue(
FileSelectListState( FileSelectListState(
listOf( listOf(
@ -545,10 +561,11 @@ class ZimManageViewModelTest {
val selectedBook = bookOnDisk(0L).apply { isSelected = true } val selectedBook = bookOnDisk(0L).apply { isSelected = true }
viewModel.fileSelectListStates.value = viewModel.fileSelectListStates.value =
FileSelectListState(listOf(selectedBook, bookOnDisk(1L)), NORMAL) FileSelectListState(listOf(selectedBook, bookOnDisk(1L)), NORMAL)
viewModel.sideEffects.test(this) testFlow(
.also { viewModel.fileSelectActions.emit(RequestSelect(selectedBook)) } flow = viewModel.sideEffects,
.assertValues(mutableListOf(None)) triggerAction = { viewModel.fileSelectActions.emit(RequestSelect(selectedBook)) },
.finish() assert = { assertThat(awaitItem()).isEqualTo(None) }
)
viewModel.fileSelectListStates.test().assertValue( viewModel.fileSelectListStates.test().assertValue(
FileSelectListState( FileSelectListState(
listOf( listOf(
@ -561,10 +578,26 @@ class ZimManageViewModelTest {
@Test @Test
fun `RestartActionMode offers StartMultiSelection`() = runTest { fun `RestartActionMode offers StartMultiSelection`() = runTest {
viewModel.sideEffects.test(this) testFlow(
.also { viewModel.fileSelectActions.emit(RestartActionMode) } flow = viewModel.sideEffects,
.assertValues(mutableListOf(StartMultiSelection(viewModel.fileSelectActions))) triggerAction = { viewModel.fileSelectActions.emit(RestartActionMode) },
.finish() 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:" + const val kotlinx_coroutines_test: String = "org.jetbrains.kotlinx:kotlinx-coroutines-test:" +
Versions.org_jetbrains_kotlinx_kotlinx_coroutines 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 * https://developer.android.com/testing
*/ */

View File

@ -119,6 +119,8 @@ object Versions {
const val COMPOSE_VERSION = "1.7.8" const val COMPOSE_VERSION = "1.7.8"
const val COMPOSE_MATERIAL3 = "1.3.1" 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) testImplementation(Libs.kotlinx_coroutines_test)
implementation(Libs.kotlinx_coroutines_android) implementation(Libs.kotlinx_coroutines_android)
implementation(Libs.zxing) 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.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test 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
import org.kiwix.kiwixmobile.core.reader.ZimFileReader.Factory import org.kiwix.kiwixmobile.core.reader.ZimFileReader.Factory
import org.kiwix.kiwixmobile.core.reader.ZimReaderSource 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.SharedPreferenceUtil
import org.kiwix.kiwixmobile.core.utils.files.FileSearch import org.kiwix.kiwixmobile.core.utils.files.FileSearch
import org.kiwix.kiwixmobile.core.utils.files.ScanningProgressListener 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.book
import org.kiwix.sharedFunctions.bookOnDisk import org.kiwix.sharedFunctions.bookOnDisk
import org.kiwix.sharedFunctions.resetSchedulers import org.kiwix.sharedFunctions.resetSchedulers
@ -86,7 +87,11 @@ class StorageObserverTest {
@Test @Test
fun `books from disk are filtered by current downloads`() = runTest { fun `books from disk are filtered by current downloads`() = runTest {
withFiltering() withFiltering()
booksOnFileSystem(this).assertValues(mutableListOf(mutableListOf())).finish() testFlow(
flow = booksOnFileSystem(),
triggerAction = {},
assert = { assertThat(awaitItem()).isEqualTo(listOf<BookOnDisk>()) }
)
} }
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@ -100,23 +105,25 @@ class StorageObserverTest {
withNoFiltering() withNoFiltering()
every { zimFileReader.toBook() } returns expectedBook every { zimFileReader.toBook() } returns expectedBook
every { zimFileReader.zimReaderSource } returns zimReaderSource every { zimFileReader.zimReaderSource } returns zimReaderSource
val testObserver = booksOnFileSystem(this) testFlow(
testObserver.assertValues( flow = booksOnFileSystem(),
mutableListOf( triggerAction = {},
mutableListOf( assert = {
assertThat(awaitItem()).isEqualTo(
listOf<BookOnDisk>(
bookOnDisk( bookOnDisk(
book = expectedBook, book = expectedBook,
zimReaderSource = zimReaderSource zimReaderSource = zimReaderSource
) )
) )
) )
).finish() }
)
verify { zimFileReader.dispose() } verify { zimFileReader.dispose() }
} }
private fun booksOnFileSystem(testScope: TestScope) = private fun booksOnFileSystem() =
storageObserver.getBooksOnFileSystem(scanningProgressListener) storageObserver.getBooksOnFileSystem(scanningProgressListener)
.test(testScope)
.also { .also {
downloads.value = listOf(downloadModel) downloads.value = listOf(downloadModel)
files.value = listOf(file) files.value = listOf(file)

View File

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

View File

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