From d63a0f2c3e6b5a893044e31e64634051cd102b6f Mon Sep 17 00:00:00 2001 From: Mostafa Mohammed Date: Mon, 18 Aug 2025 17:16:16 +0300 Subject: [PATCH 01/10] feat(onboarding): Add OnboardingViewModel tests and improve effect handling This commit introduces comprehensive unit tests for `OnboardingViewModel`, covering various scenarios like page changes, scrolling, and navigation. Key changes: - Added `OnboardingViewModelTest.kt` with tests for all public methods and state updates. - Refactored `onboardingScreen.kt` to use `Listen` for handling `OnboardingEffect`, simplifying the effect handling logic and removing `LaunchedEffect`. - Removed unnecessary logging in `OnboardingViewModel` for `onboardingFinished`. - Updated `welcomeScreen.kt` to use `onNavigateAsGuest` instead of `onNavigateContinue` for better clarity. - Included `kotlin-test` dependency in `presentation/build.gradle.kts` for unit testing. - Updated `onboardingNavGraph.kt` to reflect the name change from `onNavigateContinue` to `onNavigateAsGuest`. --- .../navigation/graph/onboardingNavGraph.kt | 2 +- presentation/build.gradle.kts | 1 + .../welcome/onboarding/OnboardingViewModel.kt | 2 - .../welcome/onboarding/onboardingScreen.kt | 40 +-- .../welcome/onboarding/welcomeScreen.kt | 10 +- .../onboarding/OnboardingViewModelTest.kt | 339 ++++++++++++++++++ 6 files changed, 360 insertions(+), 34 deletions(-) create mode 100644 presentation/src/test/java/com/london/presentation/feature/onboarding/OnboardingViewModelTest.kt diff --git a/app/src/main/java/com/london/app/navigation/graph/onboardingNavGraph.kt b/app/src/main/java/com/london/app/navigation/graph/onboardingNavGraph.kt index a3910e1f2..c03c6c31d 100644 --- a/app/src/main/java/com/london/app/navigation/graph/onboardingNavGraph.kt +++ b/app/src/main/java/com/london/app/navigation/graph/onboardingNavGraph.kt @@ -24,7 +24,7 @@ fun NavGraphBuilder.onboardingNavGraph( composable { WelcomeScreen( onNavigateLogin = navController::navigateToAuthGraph, - onNavigateContinue = navController::navigateToMainGraph + onNavigateAsGuest = navController::navigateToMainGraph ) } } \ No newline at end of file diff --git a/presentation/build.gradle.kts b/presentation/build.gradle.kts index d88fd2253..e89234d0f 100644 --- a/presentation/build.gradle.kts +++ b/presentation/build.gradle.kts @@ -67,4 +67,5 @@ dependencies { ksp(libs.bundles.hilt.ksp) implementation(libs.bundles.hilt.runtime) + testImplementation(kotlin("test")) } diff --git a/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingViewModel.kt b/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingViewModel.kt index 822f49db0..f0fde400c 100644 --- a/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingViewModel.kt +++ b/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingViewModel.kt @@ -1,6 +1,5 @@ package com.london.presentation.feature.welcome.onboarding -import android.util.Log import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.foundation.pager.PagerState @@ -58,7 +57,6 @@ class OnboardingViewModel @Inject constructor( fun onboardingFinished() { viewModelScope.launch(Dispatchers.IO) { runCatching { appPreferencesService.setOnBoardingShown() } - .onFailure { Log.e("OnboardingViewModel", "onboardingFinished: ", it) } } } diff --git a/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/onboardingScreen.kt b/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/onboardingScreen.kt index ae71bec6a..dadaba3bf 100644 --- a/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/onboardingScreen.kt +++ b/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/onboardingScreen.kt @@ -47,6 +47,7 @@ import com.london.designsystem.theme.NovixTheme import com.london.designsystem.theme.ThemePreviews import com.london.designsystem.utils.painter import com.london.presentation.R +import com.london.presentation.utils.Listen import kotlinx.coroutines.CoroutineScope @Composable @@ -63,7 +64,19 @@ fun OnboardingScreen( ) HandlePagerStateChanges(pagerState, viewModel) - HandleEffects(effect, viewModel, pagerState, scope, onComplete) + + effect?.Listen { currentEffect -> + when (currentEffect) { + is OnboardingEffect.ScrollToPage -> { + viewModel.scrollToPage(pagerState, currentEffect.page, scope) + } + + OnboardingEffect.NavigateToWelcome -> { + viewModel.onboardingFinished() + onComplete() + } + } + } Box( modifier = Modifier @@ -85,31 +98,6 @@ fun OnboardingScreen( } } -@Composable -private fun HandleEffects( - effect: OnboardingEffect?, - viewModel: OnboardingViewModel, - pagerState: PagerState, - scope: CoroutineScope, - onComplete: () -> Unit -) { - LaunchedEffect(effect) { - when (effect) { - is OnboardingEffect.ScrollToPage -> { - viewModel.scrollToPage(pagerState, effect.page, scope) - } - - OnboardingEffect.NavigateToWelcome -> { - viewModel.onboardingFinished() - onComplete() - } - - null -> {} - } - } -} - - @Composable private fun Content( pagerState: PagerState, diff --git a/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/welcomeScreen.kt b/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/welcomeScreen.kt index 5e4438282..498b04a38 100644 --- a/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/welcomeScreen.kt +++ b/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/welcomeScreen.kt @@ -38,7 +38,7 @@ import com.london.presentation.R @Composable fun WelcomeScreen( onNavigateLogin: () -> Unit, - onNavigateContinue: () -> Unit, + onNavigateAsGuest: () -> Unit, ) { val screenHeight = with(LocalDensity.current) { @@ -62,7 +62,7 @@ fun WelcomeScreen( } Content( onNavigateLogin = onNavigateLogin, - onNavigateContinue = onNavigateContinue, + onNavigateAsGuest = onNavigateAsGuest, modifier = Modifier.padding( bottom = WindowInsets.navigationBars.asPaddingValues() .calculateBottomPadding() @@ -73,7 +73,7 @@ fun WelcomeScreen( @Composable private fun Content( onNavigateLogin: () -> Unit, - onNavigateContinue: () -> Unit, + onNavigateAsGuest: () -> Unit, modifier: Modifier = Modifier ) { Column( @@ -121,7 +121,7 @@ private fun Content( OutlineButton( text = stringResource(R.string.continue_as_guest), - onClick = onNavigateContinue, + onClick = onNavigateAsGuest, modifier = Modifier.fillMaxWidth(), hasLabel = true, icon = null, @@ -175,7 +175,7 @@ fun WelcomePoster(modifier: Modifier = Modifier) { @Composable fun PreviewWelcomeScreen() { WelcomeScreen( - onNavigateContinue = {}, + onNavigateAsGuest = {}, onNavigateLogin = {} ) } \ No newline at end of file diff --git a/presentation/src/test/java/com/london/presentation/feature/onboarding/OnboardingViewModelTest.kt b/presentation/src/test/java/com/london/presentation/feature/onboarding/OnboardingViewModelTest.kt new file mode 100644 index 000000000..1e8382396 --- /dev/null +++ b/presentation/src/test/java/com/london/presentation/feature/onboarding/OnboardingViewModelTest.kt @@ -0,0 +1,339 @@ +package com.london.presentation.feature.onboarding + +import androidx.compose.foundation.pager.PagerState +import androidx.lifecycle.viewModelScope +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.london.domain.AppPreferencesService +import com.london.presentation.feature.welcome.onboarding.OnboardingEffect +import com.london.presentation.feature.welcome.onboarding.OnboardingViewModel +import io.mockk.MockKAnnotations +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class OnboardingViewModelTest { + private lateinit var appPreferencesService: AppPreferencesService + private val pagerState = mockk(relaxed = true) + private var viewModel: OnboardingViewModel? = null + private val mainDispatcher = StandardTestDispatcher() + + @Before + fun setup() { + Dispatchers.setMain(mainDispatcher) + MockKAnnotations.init(this) + setupDefaultMocks() + viewModel = createViewModel() + } + + private fun setupDefaultMocks() { + appPreferencesService = mockk() + every { pagerState.currentPage } returns 0 + every { pagerState.pageCount } returns 3 + coEvery { pagerState.animateScrollToPage(any(), any()) } just Runs + coEvery { appPreferencesService.setOnBoardingShown() } just Runs + } + + private fun createViewModel() = OnboardingViewModel(appPreferencesService) + + @After + fun tearDown() { + Dispatchers.resetMain() + viewModel?.viewModelScope?.cancel() + viewModel = null + } + + @Test + fun `when viewModel is initialized, currentPage should be 0`(): Unit = runTest { + // When + advanceUntilIdle() + + // Then + viewModel?.state?.test { + val state = expectMostRecentItem() + assertThat(state.currentPage).isEqualTo(0) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `when page is changed, currentPage should be updated`() = runTest { + // Given + val targetPage = 2 + + // When + viewModel?.onPageChanged(targetPage) + advanceUntilIdle() + + // Then + viewModel?.state?.test { + val state = expectMostRecentItem() + assertThat(state.currentPage).isEqualTo(targetPage) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `when scrollToPage is called with valid page, animation should be triggered`() = runTest { + // Given + val targetPage = 1 + val scope = this + + // When + viewModel?.scrollToPage(pagerState, targetPage, scope) + advanceUntilIdle() + + // Then + coVerify(exactly = 1) { + pagerState.animateScrollToPage( + page = targetPage, + animationSpec = any() + ) + } + } + + @Test + fun `when scrollToPage is called with invalid negative page, animation should not be triggered`() = + runTest { + // Given + val invalidPage = -1 + val scope = this + + // When + viewModel?.scrollToPage(pagerState, invalidPage, scope) + advanceUntilIdle() + + // Then + coVerify(exactly = 0) { + pagerState.animateScrollToPage(any(), any()) + } + } + + @Test + fun `when scrollToPage is called with page exceeding count, animation should not be triggered`() = + runTest { + // Given + val invalidPage = 5 // pageCount is 3 + val scope = this + + // When + viewModel?.scrollToPage(pagerState, invalidPage, scope) + advanceUntilIdle() + + // Then + coVerify(exactly = 0) { + pagerState.animateScrollToPage(any(), any()) + } + } + + @Test + fun `when scrollPrevious is called from middle page, previous page should be navigated`() = + runTest { + // Given + every { pagerState.currentPage } returns 2 + val scope = this + + // When + viewModel?.scrollPrevious(pagerState, scope) + advanceUntilIdle() + + // Then + coVerify(exactly = 1) { + pagerState.animateScrollToPage( + page = 1, + animationSpec = any() + ) + } + } + + @Test + fun `when scrollPrevious is called from first page, navigation should not happen`() = runTest { + // Given + every { pagerState.currentPage } returns 0 + val scope = this + + // When + viewModel?.scrollPrevious(pagerState, scope) + advanceUntilIdle() + + // Then + coVerify(exactly = 0) { + pagerState.animateScrollToPage(any(), any()) + } + } + + @Test + fun `when scrollNext is called from middle page, next page should be navigated`() = runTest { + // Given + every { pagerState.currentPage } returns 1 + val scope = this + + // When + viewModel?.scrollNext(pagerState, scope) + advanceUntilIdle() + + // Then + coVerify(exactly = 1) { + pagerState.animateScrollToPage( + page = 2, + animationSpec = any() + ) + } + } + + @Test + fun `when scrollNext is called from last page, welcome navigation effect should be emitted`() = + runTest { + // Given + every { pagerState.currentPage } returns 2 // Last page + val scope = this + + // When & Then + viewModel?.effect?.test { + viewModel?.scrollNext(pagerState, scope) + advanceUntilIdle() + + assertThat(awaitItem()).isEqualTo(OnboardingEffect.NavigateToWelcome) + } + } + + @Test + fun `when scrollNext is called from last page, onboarding should be marked as shown`() = + runTest { + // Given + every { pagerState.currentPage } returns 2 // Last page + val scope = this + + // When + viewModel?.scrollNext(pagerState, scope) + advanceUntilIdle() + + // Then + coVerify(exactly = 1) { + appPreferencesService.setOnBoardingShown() + } + } + + @Test + fun `when navigateToWelcome is called, welcome navigation effect should be emitted`() = + runTest { + // When & Then + viewModel?.effect?.test { + viewModel?.navigateToWelcome() + assertThat(awaitItem()).isEqualTo(OnboardingEffect.NavigateToWelcome) + } + } + + @Test + fun `when navigateToWelcome is called, onboarding should be marked as shown`() = runTest { + // When + viewModel?.navigateToWelcome() + advanceUntilIdle() + + // Then + coVerify(exactly = 1) { + appPreferencesService.setOnBoardingShown() + } + } + + @Test + fun `when onboardingFinished is called, preferences service should be invoked`() = runTest { + // When + viewModel?.onboardingFinished() + advanceUntilIdle() + + // Then + coVerify(exactly = 1) { + appPreferencesService.setOnBoardingShown() + } + } + + @Test + fun `when onboardingFinished fails, exception should be handled gracefully`() = runTest { + // Given + coEvery { appPreferencesService.setOnBoardingShown() } throws Exception("Network error") + + // When + viewModel?.onboardingFinished() + advanceUntilIdle() + + // Then - Should not crash and service should still be called + coVerify(exactly = 1) { + appPreferencesService.setOnBoardingShown() + } + } + + @Test + fun `when page changes multiple times, state should be updated correctly`() = runTest { + // When + viewModel?.onPageChanged(1) + advanceUntilIdle() + + viewModel?.state?.test { + val firstState = expectMostRecentItem() + assertThat(firstState.currentPage).isEqualTo(1) + + viewModel?.onPageChanged(2) + advanceUntilIdle() + + val secondState = expectMostRecentItem() + assertThat(secondState.currentPage).isEqualTo(2) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `when scrollToPage is called with first page, animation should work correctly`() = runTest { + // Given + val firstPage = 0 + val scope = this + + // When + viewModel?.scrollToPage(pagerState, firstPage, scope) + advanceUntilIdle() + + // Then + coVerify(exactly = 1) { + pagerState.animateScrollToPage( + page = firstPage, + animationSpec = any() + ) + } + } + + @Test + fun `when scrollToPage is called with last page, animation should work correctly`() = runTest { + // Given + val lastPage = 2 // pageCount - 1 + val scope = this + + // When + viewModel?.scrollToPage(pagerState, lastPage, scope) + advanceUntilIdle() + + // Then + coVerify(exactly = 1) { + pagerState.animateScrollToPage( + page = lastPage, + animationSpec = any() + ) + } + } + +} \ No newline at end of file From 1c7accbac53feedd67f12b5c9c6c5af3ff518145 Mon Sep 17 00:00:00 2001 From: Mostafa Mohammed Date: Tue, 19 Aug 2025 15:24:01 +0300 Subject: [PATCH 02/10] Refactor: Rename OnboardingEffect.NavigateToWelcome to OnWelcomeNavigation This commit renames the `OnboardingEffect.NavigateToWelcome` to `OnboardingEffect.OnWelcomeNavigation` for better clarity. Additionally, it removes an unused `testImplementation(kotlin("test"))` dependency from the `presentation/build.gradle.kts` file. --- presentation/build.gradle.kts | 1 - .../feature/welcome/onboarding/OnboardingEffect.kt | 2 +- .../feature/welcome/onboarding/OnboardingViewModel.kt | 2 +- .../feature/welcome/onboarding/onboardingScreen.kt | 2 +- .../feature/onboarding/OnboardingViewModelTest.kt | 4 ++-- 5 files changed, 5 insertions(+), 6 deletions(-) diff --git a/presentation/build.gradle.kts b/presentation/build.gradle.kts index e89234d0f..d88fd2253 100644 --- a/presentation/build.gradle.kts +++ b/presentation/build.gradle.kts @@ -67,5 +67,4 @@ dependencies { ksp(libs.bundles.hilt.ksp) implementation(libs.bundles.hilt.runtime) - testImplementation(kotlin("test")) } diff --git a/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingEffect.kt b/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingEffect.kt index 96e043c27..124a88e40 100644 --- a/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingEffect.kt +++ b/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingEffect.kt @@ -1,6 +1,6 @@ package com.london.presentation.feature.welcome.onboarding sealed interface OnboardingEffect { - data object NavigateToWelcome : OnboardingEffect + data object OnWelcomeNavigation : OnboardingEffect data class ScrollToPage(val page: Int) : OnboardingEffect } diff --git a/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingViewModel.kt b/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingViewModel.kt index f0fde400c..c04aa02da 100644 --- a/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingViewModel.kt +++ b/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingViewModel.kt @@ -51,7 +51,7 @@ class OnboardingViewModel @Inject constructor( fun navigateToWelcome() { setOnBoardingShown() - emitEffect(OnboardingEffect.NavigateToWelcome) + emitEffect(OnboardingEffect.OnWelcomeNavigation) } fun onboardingFinished() { diff --git a/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/onboardingScreen.kt b/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/onboardingScreen.kt index dadaba3bf..6073aff84 100644 --- a/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/onboardingScreen.kt +++ b/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/onboardingScreen.kt @@ -71,7 +71,7 @@ fun OnboardingScreen( viewModel.scrollToPage(pagerState, currentEffect.page, scope) } - OnboardingEffect.NavigateToWelcome -> { + OnboardingEffect.OnWelcomeNavigation -> { viewModel.onboardingFinished() onComplete() } diff --git a/presentation/src/test/java/com/london/presentation/feature/onboarding/OnboardingViewModelTest.kt b/presentation/src/test/java/com/london/presentation/feature/onboarding/OnboardingViewModelTest.kt index 1e8382396..eb772f1f2 100644 --- a/presentation/src/test/java/com/london/presentation/feature/onboarding/OnboardingViewModelTest.kt +++ b/presentation/src/test/java/com/london/presentation/feature/onboarding/OnboardingViewModelTest.kt @@ -208,7 +208,7 @@ class OnboardingViewModelTest { viewModel?.scrollNext(pagerState, scope) advanceUntilIdle() - assertThat(awaitItem()).isEqualTo(OnboardingEffect.NavigateToWelcome) + assertThat(awaitItem()).isEqualTo(OnboardingEffect.OnWelcomeNavigation) } } @@ -235,7 +235,7 @@ class OnboardingViewModelTest { // When & Then viewModel?.effect?.test { viewModel?.navigateToWelcome() - assertThat(awaitItem()).isEqualTo(OnboardingEffect.NavigateToWelcome) + assertThat(awaitItem()).isEqualTo(OnboardingEffect.OnWelcomeNavigation) } } From 56461c127492f275bd245d1adddbcfe93224cc2b Mon Sep 17 00:00:00 2001 From: Mostafa Mohammed Date: Tue, 19 Aug 2025 16:09:21 +0300 Subject: [PATCH 03/10] Refactor: Use tryToExecute for preference setting Refactors the `onboardingFinished` and `setOnBoardingShown` functions in `OnboardingViewModel` to use the `tryToExecute` extension function for setting the onboarding shown preference. This improves error handling and logging. --- .../welcome/onboarding/OnboardingViewModel.kt | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingViewModel.kt b/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingViewModel.kt index c04aa02da..b53268718 100644 --- a/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingViewModel.kt +++ b/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingViewModel.kt @@ -3,13 +3,12 @@ package com.london.presentation.feature.welcome.onboarding import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.foundation.pager.PagerState -import androidx.lifecycle.viewModelScope import com.london.domain.AppPreferencesService import com.london.presentation.shared.base.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject @HiltViewModel @@ -17,7 +16,6 @@ class OnboardingViewModel @Inject constructor( private val appPreferencesService: AppPreferencesService ) : BaseViewModel(OnboardingUiState()) { - fun onPageChanged(page: Int) { updateState { copy(currentPage = page) } } @@ -55,14 +53,20 @@ class OnboardingViewModel @Inject constructor( } fun onboardingFinished() { - viewModelScope.launch(Dispatchers.IO) { - runCatching { appPreferencesService.setOnBoardingShown() } - } + tryToExecute( + block = { appPreferencesService.setOnBoardingShown() }, + onError = { errorState -> + Timber.e("Failed to set onboarding shown: $errorState") + } + ) } private fun setOnBoardingShown() { - viewModelScope.launch(Dispatchers.IO) { - runCatching { appPreferencesService.setOnBoardingShown() } - } + tryToExecute( + block = { appPreferencesService.setOnBoardingShown() }, + onError = { errorState -> + Timber.e("Failed to set onboarding shown: $errorState") + } + ) } -} \ No newline at end of file +} From 08d78f62579341d61e14b281422438e3e594ba5a Mon Sep 17 00:00:00 2001 From: Mostafa Mohammed Date: Tue, 19 Aug 2025 20:15:09 +0300 Subject: [PATCH 04/10] Test: Improve OnboardingViewModelTest stability This commit addresses potential flakiness in `OnboardingViewModelTest` by: - Introducing `advanceTimeBy(1000)` and `advanceUntilIdle()` in tests involving `onboardingFinished()` and `navigateToWelcome()`. This ensures that IO operations (specifically, `appPreferencesService.setOnBoardingShown()`) have sufficient time to complete before assertions are made. - Adding an alternative test case (`onboardingFinished is called, preferences service should be invoked - with timeout`) that utilizes `coVerify(timeout = 2000)` as another way to handle asynchronous operations, providing more robust verification. These changes enhance the reliability of the tests by better managing asynchronous operations within the `runTest` environment. --- .../welcome/onboarding/OnboardingViewModel.kt | 1 - .../onboarding/OnboardingViewModelTest.kt | 48 ++++++++++++++----- 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingViewModel.kt b/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingViewModel.kt index c8e389e93..f99cf0a10 100644 --- a/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingViewModel.kt +++ b/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingViewModel.kt @@ -3,7 +3,6 @@ package com.london.presentation.feature.welcome.onboarding import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.foundation.pager.PagerState -import androidx.lifecycle.viewModelScope import com.london.domain.service.AppPreferencesService import com.london.presentation.shared.base.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel diff --git a/presentation/src/test/java/com/london/presentation/feature/onboarding/OnboardingViewModelTest.kt b/presentation/src/test/java/com/london/presentation/feature/onboarding/OnboardingViewModelTest.kt index eb772f1f2..23c8466b2 100644 --- a/presentation/src/test/java/com/london/presentation/feature/onboarding/OnboardingViewModelTest.kt +++ b/presentation/src/test/java/com/london/presentation/feature/onboarding/OnboardingViewModelTest.kt @@ -4,7 +4,7 @@ import androidx.compose.foundation.pager.PagerState import androidx.lifecycle.viewModelScope import app.cash.turbine.test import com.google.common.truth.Truth.assertThat -import com.london.domain.AppPreferencesService +import com.london.domain.service.AppPreferencesService import com.london.presentation.feature.welcome.onboarding.OnboardingEffect import com.london.presentation.feature.welcome.onboarding.OnboardingViewModel import io.mockk.MockKAnnotations @@ -18,6 +18,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.cancel import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest @@ -240,9 +241,12 @@ class OnboardingViewModelTest { } @Test - fun `when navigateToWelcome is called, onboarding should be marked as shown`() = runTest { + fun `when onboardingFinished is called, preferences service should be invoked`() = runTest { // When - viewModel?.navigateToWelcome() + viewModel?.onboardingFinished() + + // Wait for IO operations to complete + advanceTimeBy(1000) // Advance virtual time advanceUntilIdle() // Then @@ -252,32 +256,52 @@ class OnboardingViewModelTest { } @Test - fun `when onboardingFinished is called, preferences service should be invoked`() = runTest { + fun `when onboardingFinished fails, exception should be handled gracefully`() = runTest { + // Given + coEvery { appPreferencesService.setOnBoardingShown() } throws Exception("Network error") + // When viewModel?.onboardingFinished() + + // Wait for IO operations to complete + advanceTimeBy(1000) advanceUntilIdle() - // Then + // Then - Should not crash and service should still be called coVerify(exactly = 1) { appPreferencesService.setOnBoardingShown() } } @Test - fun `when onboardingFinished fails, exception should be handled gracefully`() = runTest { - // Given - coEvery { appPreferencesService.setOnBoardingShown() } throws Exception("Network error") - + fun `when navigateToWelcome is called, onboarding should be marked as shown`() = runTest { // When - viewModel?.onboardingFinished() + viewModel?.navigateToWelcome() + + // Wait for IO operations to complete + advanceTimeBy(1000) advanceUntilIdle() - // Then - Should not crash and service should still be called + // Then coVerify(exactly = 1) { appPreferencesService.setOnBoardingShown() } } + // Alternative approach using coVerify with timeout + @Test + fun `when onboardingFinished is called, preferences service should be invoked - with timeout`() = + runTest { + // When + viewModel?.onboardingFinished() + advanceUntilIdle() + + // Then - Use timeout in coVerify to wait for async operations + coVerify(timeout = 2000, exactly = 1) { + appPreferencesService.setOnBoardingShown() + } + } + @Test fun `when page changes multiple times, state should be updated correctly`() = runTest { // When @@ -336,4 +360,4 @@ class OnboardingViewModelTest { } } -} \ No newline at end of file +} From e74dd19f0172aacad2143e699f6b30f8f79faca9 Mon Sep 17 00:00:00 2001 From: Mostafa Mohammed Date: Wed, 20 Aug 2025 19:36:02 +0300 Subject: [PATCH 05/10] test(OnboardingViewModel): Advance time in scrollNext test The `scrollNext` function in `OnboardingViewModel` involves a coroutine scope for scrolling the Pager. This commit adds `advanceTimeBy(1000)` to the corresponding test case to ensure the coroutine has enough time to complete, preventing potential flakiness. --- .../presentation/feature/onboarding/OnboardingViewModelTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/presentation/src/test/java/com/london/presentation/feature/onboarding/OnboardingViewModelTest.kt b/presentation/src/test/java/com/london/presentation/feature/onboarding/OnboardingViewModelTest.kt index 23c8466b2..96f7f7e80 100644 --- a/presentation/src/test/java/com/london/presentation/feature/onboarding/OnboardingViewModelTest.kt +++ b/presentation/src/test/java/com/london/presentation/feature/onboarding/OnboardingViewModelTest.kt @@ -222,6 +222,7 @@ class OnboardingViewModelTest { // When viewModel?.scrollNext(pagerState, scope) + advanceTimeBy(1000) advanceUntilIdle() // Then From 6fbe134709facc10dbeff667c803aaa109c6eb6f Mon Sep 17 00:00:00 2001 From: Mostafa Mohammed Date: Wed, 20 Aug 2025 20:32:49 +0300 Subject: [PATCH 06/10] test(OnboardingViewModel): Remove redundant comments --- .../feature/onboarding/OnboardingViewModelTest.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/presentation/src/test/java/com/london/presentation/feature/onboarding/OnboardingViewModelTest.kt b/presentation/src/test/java/com/london/presentation/feature/onboarding/OnboardingViewModelTest.kt index 96f7f7e80..6315c2212 100644 --- a/presentation/src/test/java/com/london/presentation/feature/onboarding/OnboardingViewModelTest.kt +++ b/presentation/src/test/java/com/london/presentation/feature/onboarding/OnboardingViewModelTest.kt @@ -247,7 +247,7 @@ class OnboardingViewModelTest { viewModel?.onboardingFinished() // Wait for IO operations to complete - advanceTimeBy(1000) // Advance virtual time + advanceTimeBy(1000) advanceUntilIdle() // Then @@ -268,7 +268,6 @@ class OnboardingViewModelTest { advanceTimeBy(1000) advanceUntilIdle() - // Then - Should not crash and service should still be called coVerify(exactly = 1) { appPreferencesService.setOnBoardingShown() } @@ -289,7 +288,6 @@ class OnboardingViewModelTest { } } - // Alternative approach using coVerify with timeout @Test fun `when onboardingFinished is called, preferences service should be invoked - with timeout`() = runTest { @@ -345,7 +343,7 @@ class OnboardingViewModelTest { @Test fun `when scrollToPage is called with last page, animation should work correctly`() = runTest { // Given - val lastPage = 2 // pageCount - 1 + val lastPage = 2 val scope = this // When From 50a8de649acd332b5828f49bb1cc11e1c3c8b3e8 Mon Sep 17 00:00:00 2001 From: Mostafa Mohammed Date: Wed, 20 Aug 2025 20:45:55 +0300 Subject: [PATCH 07/10] test(OnboardingViewModel): Replace `advanceTimeBy` with `withTimeout` and `coVerify` with `timeout` This commit refactors tests in `OnboardingViewModelTest` to improve reliability and readability. - `advanceTimeBy(1000)` has been replaced with `withTimeout(2000)` when asserting navigation effects. This provides a more explicit timeout mechanism for operations that might take longer. - For `coVerify` calls that interact with `appPreferencesService`, a `timeout = 2000` parameter has been added directly to the `coVerify` function. This ensures the verification waits for the asynchronous preference setting operation to complete. These changes make the tests more robust by explicitly handling potential timeouts for asynchronous operations, particularly those involving I/O like preference updates and navigation. --- .../onboarding/OnboardingViewModelTest.kt | 49 ++++++------------- 1 file changed, 14 insertions(+), 35 deletions(-) diff --git a/presentation/src/test/java/com/london/presentation/feature/onboarding/OnboardingViewModelTest.kt b/presentation/src/test/java/com/london/presentation/feature/onboarding/OnboardingViewModelTest.kt index 6315c2212..ece1e3854 100644 --- a/presentation/src/test/java/com/london/presentation/feature/onboarding/OnboardingViewModelTest.kt +++ b/presentation/src/test/java/com/london/presentation/feature/onboarding/OnboardingViewModelTest.kt @@ -18,11 +18,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.cancel import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout import org.junit.After import org.junit.Before import org.junit.Test @@ -209,7 +209,9 @@ class OnboardingViewModelTest { viewModel?.scrollNext(pagerState, scope) advanceUntilIdle() - assertThat(awaitItem()).isEqualTo(OnboardingEffect.OnWelcomeNavigation) + withTimeout(2000) { + assertThat(awaitItem()).isEqualTo(OnboardingEffect.OnWelcomeNavigation) + } } } @@ -222,11 +224,10 @@ class OnboardingViewModelTest { // When viewModel?.scrollNext(pagerState, scope) - advanceTimeBy(1000) advanceUntilIdle() - // Then - coVerify(exactly = 1) { + // Then - Use timeout for IO operations + coVerify(timeout = 2000, exactly = 1) { appPreferencesService.setOnBoardingShown() } } @@ -237,7 +238,10 @@ class OnboardingViewModelTest { // When & Then viewModel?.effect?.test { viewModel?.navigateToWelcome() - assertThat(awaitItem()).isEqualTo(OnboardingEffect.OnWelcomeNavigation) + + withTimeout(2000) { + assertThat(awaitItem()).isEqualTo(OnboardingEffect.OnWelcomeNavigation) + } } } @@ -245,13 +249,9 @@ class OnboardingViewModelTest { fun `when onboardingFinished is called, preferences service should be invoked`() = runTest { // When viewModel?.onboardingFinished() - - // Wait for IO operations to complete - advanceTimeBy(1000) advanceUntilIdle() - // Then - coVerify(exactly = 1) { + coVerify(timeout = 2000, exactly = 1) { appPreferencesService.setOnBoardingShown() } } @@ -263,12 +263,9 @@ class OnboardingViewModelTest { // When viewModel?.onboardingFinished() - - // Wait for IO operations to complete - advanceTimeBy(1000) advanceUntilIdle() - coVerify(exactly = 1) { + coVerify(timeout = 2000, exactly = 1) { appPreferencesService.setOnBoardingShown() } } @@ -277,30 +274,13 @@ class OnboardingViewModelTest { fun `when navigateToWelcome is called, onboarding should be marked as shown`() = runTest { // When viewModel?.navigateToWelcome() - - // Wait for IO operations to complete - advanceTimeBy(1000) advanceUntilIdle() - // Then - coVerify(exactly = 1) { + coVerify(timeout = 2000, exactly = 1) { appPreferencesService.setOnBoardingShown() } } - @Test - fun `when onboardingFinished is called, preferences service should be invoked - with timeout`() = - runTest { - // When - viewModel?.onboardingFinished() - advanceUntilIdle() - - // Then - Use timeout in coVerify to wait for async operations - coVerify(timeout = 2000, exactly = 1) { - appPreferencesService.setOnBoardingShown() - } - } - @Test fun `when page changes multiple times, state should be updated correctly`() = runTest { // When @@ -343,7 +323,7 @@ class OnboardingViewModelTest { @Test fun `when scrollToPage is called with last page, animation should work correctly`() = runTest { // Given - val lastPage = 2 + val lastPage = 2 // pageCount - 1 val scope = this // When @@ -358,5 +338,4 @@ class OnboardingViewModelTest { ) } } - } From ccda4b10c15a2cc3dc2182019ee8a38d42e2b9b1 Mon Sep 17 00:00:00 2001 From: Mostafa Mohammed Date: Thu, 21 Aug 2025 13:36:16 +0300 Subject: [PATCH 08/10] test(OnboardingViewModel): Remove unnecessary timeout in scrollNext test --- .../feature/onboarding/OnboardingViewModelTest.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/presentation/src/test/java/com/london/presentation/feature/onboarding/OnboardingViewModelTest.kt b/presentation/src/test/java/com/london/presentation/feature/onboarding/OnboardingViewModelTest.kt index ece1e3854..082b6c704 100644 --- a/presentation/src/test/java/com/london/presentation/feature/onboarding/OnboardingViewModelTest.kt +++ b/presentation/src/test/java/com/london/presentation/feature/onboarding/OnboardingViewModelTest.kt @@ -208,10 +208,7 @@ class OnboardingViewModelTest { viewModel?.effect?.test { viewModel?.scrollNext(pagerState, scope) advanceUntilIdle() - - withTimeout(2000) { - assertThat(awaitItem()).isEqualTo(OnboardingEffect.OnWelcomeNavigation) - } + assertThat(awaitItem()).isEqualTo(OnboardingEffect.OnWelcomeNavigation) } } From 462cbb5bac14fd896a56f86491e38e611ec73aa0 Mon Sep 17 00:00:00 2001 From: Mostafa Mohammed Date: Thu, 21 Aug 2025 13:47:07 +0300 Subject: [PATCH 09/10] test(OnboardingViewModel): Use advanceUntilIdle in navigateToWelcome test --- .../onboarding/OnboardingViewModelTest.kt | 26 +++++++------------ 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/presentation/src/test/java/com/london/presentation/feature/onboarding/OnboardingViewModelTest.kt b/presentation/src/test/java/com/london/presentation/feature/onboarding/OnboardingViewModelTest.kt index 082b6c704..3c1f6d61c 100644 --- a/presentation/src/test/java/com/london/presentation/feature/onboarding/OnboardingViewModelTest.kt +++ b/presentation/src/test/java/com/london/presentation/feature/onboarding/OnboardingViewModelTest.kt @@ -22,7 +22,6 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain -import kotlinx.coroutines.withTimeout import org.junit.After import org.junit.Before import org.junit.Test @@ -143,8 +142,7 @@ class OnboardingViewModelTest { } @Test - fun `when scrollPrevious is called from middle page, previous page should be navigated`() = - runTest { + fun `when scrollPrevious is called from middle page, previous page should be navigated`() = runTest { // Given every { pagerState.currentPage } returns 2 val scope = this @@ -198,8 +196,7 @@ class OnboardingViewModelTest { } @Test - fun `when scrollNext is called from last page, welcome navigation effect should be emitted`() = - runTest { + fun `when scrollNext is called from last page, welcome navigation effect should be emitted`() = runTest { // Given every { pagerState.currentPage } returns 2 // Last page val scope = this @@ -213,8 +210,7 @@ class OnboardingViewModelTest { } @Test - fun `when scrollNext is called from last page, onboarding should be marked as shown`() = - runTest { + fun `when scrollNext is called from last page, onboarding should be marked as shown`() = runTest { // Given every { pagerState.currentPage } returns 2 // Last page val scope = this @@ -230,17 +226,13 @@ class OnboardingViewModelTest { } @Test - fun `when navigateToWelcome is called, welcome navigation effect should be emitted`() = - runTest { - // When & Then - viewModel?.effect?.test { - viewModel?.navigateToWelcome() - - withTimeout(2000) { - assertThat(awaitItem()).isEqualTo(OnboardingEffect.OnWelcomeNavigation) - } - } + fun `when navigateToWelcome is called, welcome navigation effect should be emitted`() = runTest { + viewModel?.effect?.test { + viewModel?.navigateToWelcome() + advanceUntilIdle() + assertThat(awaitItem()).isEqualTo(OnboardingEffect.OnWelcomeNavigation) } + } @Test fun `when onboardingFinished is called, preferences service should be invoked`() = runTest { From df6cda1c666732e2715eebd1d52c39b4da88b376 Mon Sep 17 00:00:00 2001 From: Mostafa Mohammed Date: Thu, 21 Aug 2025 18:09:30 +0300 Subject: [PATCH 10/10] Refactor: Rename OnboardingEffect.OnWelcomeNavigation to WelcomeNavigation --- .../feature/welcome/onboarding/OnboardingEffect.kt | 2 +- .../feature/welcome/onboarding/OnboardingScreen.kt | 2 +- .../feature/welcome/onboarding/OnboardingViewModel.kt | 2 +- .../feature/onboarding/OnboardingViewModelTest.kt | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingEffect.kt b/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingEffect.kt index 124a88e40..0a8fb9a39 100644 --- a/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingEffect.kt +++ b/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingEffect.kt @@ -1,6 +1,6 @@ package com.london.presentation.feature.welcome.onboarding sealed interface OnboardingEffect { - data object OnWelcomeNavigation : OnboardingEffect + data object WelcomeNavigation : OnboardingEffect data class ScrollToPage(val page: Int) : OnboardingEffect } diff --git a/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingScreen.kt b/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingScreen.kt index 0e7d56944..ea11e2b5d 100644 --- a/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingScreen.kt +++ b/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingScreen.kt @@ -74,7 +74,7 @@ fun OnboardingScreen( viewModel.scrollToPage(pagerState, currentEffect.page, scope) } - OnboardingEffect.OnWelcomeNavigation -> { + OnboardingEffect.WelcomeNavigation -> { viewModel.onboardingFinished() onComplete() } diff --git a/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingViewModel.kt b/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingViewModel.kt index f99cf0a10..6e949701b 100644 --- a/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingViewModel.kt +++ b/presentation/src/main/java/com/london/presentation/feature/welcome/onboarding/OnboardingViewModel.kt @@ -49,7 +49,7 @@ class OnboardingViewModel @Inject constructor( fun navigateToWelcome() { setOnBoardingShown() - emitEffect(OnboardingEffect.OnWelcomeNavigation) + emitEffect(OnboardingEffect.WelcomeNavigation) } fun onboardingFinished() { diff --git a/presentation/src/test/java/com/london/presentation/feature/onboarding/OnboardingViewModelTest.kt b/presentation/src/test/java/com/london/presentation/feature/onboarding/OnboardingViewModelTest.kt index 3c1f6d61c..1fbe76332 100644 --- a/presentation/src/test/java/com/london/presentation/feature/onboarding/OnboardingViewModelTest.kt +++ b/presentation/src/test/java/com/london/presentation/feature/onboarding/OnboardingViewModelTest.kt @@ -205,7 +205,7 @@ class OnboardingViewModelTest { viewModel?.effect?.test { viewModel?.scrollNext(pagerState, scope) advanceUntilIdle() - assertThat(awaitItem()).isEqualTo(OnboardingEffect.OnWelcomeNavigation) + assertThat(awaitItem()).isEqualTo(OnboardingEffect.WelcomeNavigation) } } @@ -230,7 +230,7 @@ class OnboardingViewModelTest { viewModel?.effect?.test { viewModel?.navigateToWelcome() advanceUntilIdle() - assertThat(awaitItem()).isEqualTo(OnboardingEffect.OnWelcomeNavigation) + assertThat(awaitItem()).isEqualTo(OnboardingEffect.WelcomeNavigation) } }