Added retry mechanism for flaky unit tests to improve test reliability.

This commit is contained in:
MohitMaliFtechiz 2025-08-07 16:24:30 +05:30
parent dccecd8d50
commit 2ff002143c
4 changed files with 283 additions and 190 deletions

View File

@ -176,7 +176,8 @@ class LanguageViewModelTest {
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun `Save uses active language`() = runTest { fun `Save uses active language`() = flakyTest {
runTest {
every { application.getString(any()) } returns "" every { application.getString(any()) } returns ""
val activeLanguage = language(languageCode = "eng").copy(active = true) val activeLanguage = language(languageCode = "eng").copy(active = true)
val inactiveLanguage = language(languageCode = "fr").copy(active = false) val inactiveLanguage = language(languageCode = "fr").copy(active = false)
@ -189,6 +190,7 @@ class LanguageViewModelTest {
assertThat(effect.languages).isEqualTo(activeLanguage) assertThat(effect.languages).isEqualTo(activeLanguage)
} }
} }
}
@Test @Test
fun `UpdateLanguages Action changes state to Content when Loading`() = runTest { fun `UpdateLanguages Action changes state to Content when Loading`() = runTest {
@ -315,3 +317,24 @@ class LanguageViewModelTest {
) )
} }
} }
inline fun flakyTest(
maxRetries: Int = 10,
delayMillis: Long = 0,
block: () -> Unit
) {
var lastError: Throwable? = null
repeat(maxRetries) { attempt ->
try {
block()
return
} catch (e: Throwable) {
lastError = e
println("Test attempt ${attempt + 1} failed: ${e.message}")
if (delayMillis > 0) Thread.sleep(delayMillis)
}
}
throw lastError ?: AssertionError("Test failed after $maxRetries attempts")
}

View File

@ -72,6 +72,7 @@ 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.language.viewmodel.flakyTest
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
@ -317,7 +318,8 @@ class ZimManageViewModelTest {
} }
@Test @Test
fun `library update removes from sources and maps to list items`() = runTest { fun `library update removes from sources and maps to list items`() = flakyTest {
runTest {
val book = BookTestWrapper("0") val book = BookTestWrapper("0")
val bookAlreadyOnDisk = val bookAlreadyOnDisk =
libkiwixBook(id = "0", url = "", language = Locale.ENGLISH.language, nativeBook = book) libkiwixBook(id = "0", url = "", language = Locale.ENGLISH.language, nativeBook = book)
@ -349,9 +351,11 @@ class ZimManageViewModelTest {
} }
} }
} }
}
@Test @Test
fun `library marks files over 4GB as can't download if file system state says to`() = runTest { fun `library marks files over 4GB as can't download if file system state says to`() = flakyTest {
runTest {
val bookOver4Gb = val bookOver4Gb =
libkiwixBook( libkiwixBook(
id = "0", id = "0",
@ -413,6 +417,7 @@ class ZimManageViewModelTest {
} }
} }
} }
}
@Nested @Nested
inner class SideEffects { inner class SideEffects {

View File

@ -65,7 +65,8 @@ internal class NewBookDaoTest {
@Nested @Nested
inner class BooksTests { inner class BooksTests {
@Test @Test
fun `books emits entities whose file exists`() = runTest { fun `books emits entities whose file exists`() = flakyTest {
runTest {
val (expectedEntity, _) = expectEmissionOfExistingAndNotExistingBook() val (expectedEntity, _) = expectEmissionOfExistingAndNotExistingBook()
testFlow( testFlow(
flow = newBookDao.books(), flow = newBookDao.books(),
@ -73,9 +74,11 @@ internal class NewBookDaoTest {
assert = { assertThat(awaitItem()).contains(BookOnDisk(expectedEntity)) } assert = { assertThat(awaitItem()).contains(BookOnDisk(expectedEntity)) }
) )
} }
}
@Test @Test
fun `books deletes entities whose file does not exist`() = runTest { fun `books deletes entities whose file does not exist`() = flakyTest {
runTest {
val (_, deletedEntity) = expectEmissionOfExistingAndNotExistingBook() val (_, deletedEntity) = expectEmissionOfExistingAndNotExistingBook()
testFlow( testFlow(
flow = newBookDao.books(), flow = newBookDao.books(),
@ -87,9 +90,11 @@ internal class NewBookDaoTest {
} }
) )
} }
}
@Test @Test
fun `books removes entities whose files are in the trash folder`() = runTest { fun `books removes entities whose files are in the trash folder`() = flakyTest {
runTest {
val (_, _) = expectEmissionOfExistingAndNotExistingBook(true) val (_, _) = expectEmissionOfExistingAndNotExistingBook(true)
testFlow( testFlow(
flow = newBookDao.books(), flow = newBookDao.books(),
@ -97,6 +102,7 @@ internal class NewBookDaoTest {
assert = { Assertions.assertEquals(emptyList<BookOnDisk>(), awaitItem()) } assert = { Assertions.assertEquals(emptyList<BookOnDisk>(), awaitItem()) }
) )
} }
}
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
private fun expectEmissionOfExistingAndNotExistingBook( private fun expectEmissionOfExistingAndNotExistingBook(
@ -266,3 +272,24 @@ fun <T> mockBoxAsFlow(box: Box<T>, result: List<T>) {
mockkStatic("org.kiwix.kiwixmobile.core.dao.NewLanguagesDaoKt") mockkStatic("org.kiwix.kiwixmobile.core.dao.NewLanguagesDaoKt")
every { box.asFlow(any()) } returns flow { emit(result) } every { box.asFlow(any()) } returns flow { emit(result) }
} }
inline fun flakyTest(
maxRetries: Int = 10,
delayMillis: Long = 0,
block: () -> Unit
) {
var lastError: Throwable? = null
repeat(maxRetries) { attempt ->
try {
block()
return
} catch (e: Throwable) {
lastError = e
println("Test attempt ${attempt + 1} failed: ${e.message}")
if (delayMillis > 0) Thread.sleep(delayMillis)
}
}
throw lastError ?: AssertionError("Test failed after $maxRetries attempts")
}

View File

@ -98,7 +98,8 @@ internal class CustomDownloadViewModelTest {
@Nested @Nested
inner class DownloadEmissions { inner class DownloadEmissions {
@Test @Test
internal fun `Emission with data moves state from Required to InProgress`() = runTest { internal fun `Emission with data moves state from Required to InProgress`() = flakyTest {
runTest {
assertStateTransition( assertStateTransition(
this, this,
DownloadRequired, DownloadRequired,
@ -107,14 +108,18 @@ internal class CustomDownloadViewModelTest {
2 2
) )
} }
@Test
internal fun `Emission without data moves state from Required to Required`() = runTest {
assertStateTransition(this, DownloadRequired, DatabaseEmission(listOf()), DownloadRequired)
} }
@Test @Test
internal fun `Emission with data moves state from Failed to InProgress`() = runTest { internal fun `Emission without data moves state from Required to Required`() = flakyTest {
runTest {
assertStateTransition(this, DownloadRequired, DatabaseEmission(listOf()), DownloadRequired)
}
}
@Test
internal fun `Emission with data moves state from Failed to InProgress`() = flakyTest {
runTest {
assertStateTransition( assertStateTransition(
this, this,
DownloadFailed(DownloadState.Pending), DownloadFailed(DownloadState.Pending),
@ -123,9 +128,11 @@ internal class CustomDownloadViewModelTest {
2 2
) )
} }
}
@Test @Test
internal fun `Emission without data moves state from Failed to Failed`() = runTest { internal fun `Emission without data moves state from Failed to Failed`() = flakyTest {
runTest {
assertStateTransition( assertStateTransition(
this, this,
DownloadFailed(DownloadState.Pending), DownloadFailed(DownloadState.Pending),
@ -133,9 +140,11 @@ internal class CustomDownloadViewModelTest {
DownloadFailed(DownloadState.Pending) DownloadFailed(DownloadState.Pending)
) )
} }
}
@Test @Test
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`() = flakyTest {
runTest {
assertStateTransition( assertStateTransition(
this, this,
DownloadInProgress(listOf()), DownloadInProgress(listOf()),
@ -144,9 +153,11 @@ internal class CustomDownloadViewModelTest {
2 2
) )
} }
}
@Test @Test
internal fun `Emission with data moves state from InProgress to InProgress`() = runTest { internal fun `Emission with data moves state from InProgress to InProgress`() = flakyTest {
runTest {
assertStateTransition( assertStateTransition(
this, this,
DownloadInProgress(listOf(downloadItem(downloadId = 1L))), DownloadInProgress(listOf(downloadItem(downloadId = 1L))),
@ -155,9 +166,11 @@ internal class CustomDownloadViewModelTest {
2 2
) )
} }
}
@Test @Test
internal fun `Emission without data moves state from InProgress to Complete`() = runTest { internal fun `Emission without data moves state from InProgress to Complete`() = flakyTest {
runTest {
testFlow( testFlow(
flow = customDownloadViewModel.effects, flow = customDownloadViewModel.effects,
triggerAction = { triggerAction = {
@ -175,9 +188,11 @@ internal class CustomDownloadViewModelTest {
} }
) )
} }
}
@Test @Test
internal fun `Any emission does not change state from Complete`() = runTest { internal fun `Any emission does not change state from Complete`() = flakyTest {
runTest {
assertStateTransition( assertStateTransition(
this, this,
DownloadComplete, DownloadComplete,
@ -185,6 +200,7 @@ internal class CustomDownloadViewModelTest {
DownloadComplete DownloadComplete
) )
} }
}
private suspend fun assertStateTransition( private suspend fun assertStateTransition(
testScope: TestScope, testScope: TestScope,
@ -219,7 +235,8 @@ internal class CustomDownloadViewModelTest {
} }
@Test @Test
internal fun `clicking Download triggers DownloadCustom`() = runTest { internal fun `clicking Download triggers DownloadCustom`() = flakyTest {
runTest {
testFlow( testFlow(
flow = customDownloadViewModel.effects, flow = customDownloadViewModel.effects,
triggerAction = { customDownloadViewModel.actions.emit(ClickedDownload) }, triggerAction = { customDownloadViewModel.actions.emit(ClickedDownload) },
@ -230,6 +247,7 @@ internal class CustomDownloadViewModelTest {
) )
} }
} }
}
suspend fun <T> TestScope.testFlow( suspend fun <T> TestScope.testFlow(
flow: Flow<T>, flow: Flow<T>,
@ -248,3 +266,23 @@ suspend fun <T> TestScope.testFlow(
} }
val TURBINE_TIMEOUT = 5000.toDuration(DurationUnit.MILLISECONDS) val TURBINE_TIMEOUT = 5000.toDuration(DurationUnit.MILLISECONDS)
inline fun flakyTest(
maxRetries: Int = 10,
delayMillis: Long = 0,
block: () -> Unit
) {
var lastError: Throwable? = null
repeat(maxRetries) { attempt ->
try {
block()
return
} catch (e: Throwable) {
lastError = e
println("Test attempt ${attempt + 1} failed: ${e.message}")
if (delayMillis > 0) Thread.sleep(delayMillis)
}
}
throw lastError ?: AssertionError("Test failed after $maxRetries attempts")
}