From ed148c20897052091c62dfeffb088373b5e1871e Mon Sep 17 00:00:00 2001 From: David Perez Date: Thu, 8 May 2025 14:58:03 -0500 Subject: [PATCH] PM-21252: Create mock NavHostController for navigation testing (#5159) --- .../platform/feature/rootnav/RootNavScreen.kt | 4 - .../VaultUnlockedNavBarScreen.kt | 4 - .../ui/platform/base/FakeNavHostController.kt | 191 --------- .../feature/rootnav/RootNavScreenTest.kt | 375 ++++++++++++++++++ .../VaultUnlockedNavBarScreenTest.kt | 236 +++++++++++ ui/build.gradle.kts | 1 + .../ui/platform/base/MockNavHostController.kt | 86 ++++ 7 files changed, 698 insertions(+), 199 deletions(-) delete mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/platform/base/FakeNavHostController.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt create mode 100644 ui/src/testFixtures/kotlin/com/bitwarden/ui/platform/base/MockNavHostController.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt index be53f33584..e5dc5ce0ba 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt @@ -1,6 +1,3 @@ -// TODO: Add tests for this (PM-21252) -@file:OmitFromCoverage - package com.x8bit.bitwarden.ui.platform.feature.rootnav import androidx.activity.compose.LocalActivity @@ -16,7 +13,6 @@ import androidx.navigation.NavDestination import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.navOptions -import com.bitwarden.core.annotation.OmitFromCoverage import com.bitwarden.ui.platform.theme.NonNullEnterTransitionProvider import com.bitwarden.ui.platform.theme.NonNullExitTransitionProvider import com.bitwarden.ui.platform.theme.RootTransitionProviders diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt index bb0db33b0a..0dd3e4dd92 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt @@ -1,6 +1,3 @@ -// TODO: Add tests for this (PM-21252) -@file:OmitFromCoverage - package com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar import androidx.compose.foundation.layout.WindowInsets @@ -22,7 +19,6 @@ import androidx.navigation.NavOptions import androidx.navigation.compose.NavHost import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.navOptions -import com.bitwarden.core.annotation.OmitFromCoverage import com.bitwarden.ui.platform.theme.RootTransitionProviders import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.components.model.NavigationItem diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/FakeNavHostController.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/FakeNavHostController.kt deleted file mode 100644 index 171ab02bd6..0000000000 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/FakeNavHostController.kt +++ /dev/null @@ -1,191 +0,0 @@ -package com.x8bit.bitwarden.ui.platform.base - -import android.content.Context -import android.net.Uri -import androidx.navigation.NavDeepLinkRequest -import androidx.navigation.NavDestination -import androidx.navigation.NavGraph -import androidx.navigation.NavGraphNavigator -import androidx.navigation.NavHostController -import androidx.navigation.NavOptions -import androidx.navigation.Navigator -import androidx.navigation.NavigatorProvider -import androidx.navigation.NavigatorState -import androidx.navigation.compose.ComposeNavigator -import io.mockk.every -import io.mockk.mockk -import kotlinx.coroutines.flow.MutableStateFlow -import org.junit.Assert.assertEquals - -/** - * A "fake" implementation of a [NavHostController] that serves as an alternative to the direct - * use of a [TestNavHostController](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:navigation/navigation-testing/src/main/java/androidx/navigation/testing/TestNavHostController.kt?q=TestNavHostController). - * - * The primary features of this implementations are: - * - * - Access to a [Context] is not required, so the class can be instantiated immediately in a test. - * - Requested navigation is never actually performed. Instead, a record of the [lastNavigation] - * params is provided, as well as what would be the [currentRoute] if that navigation was - * performed. - * - Helper functions like [assertCurrentRoute] are provided to make clear what kind of - * functionality is probably mocked/faked and suitable for testing. These are the recommended - * way to interact with this class. - * - * Note that this should only be used in cases where testing that a navigation is requested but - * not performed is sufficient (i.e. in focused tests of a single Composable screen, rather than - * an actual navigation flow). Because the initial setting of the graph is also stubbed out, tests - * of Composable screens that represent the host for a nested navigation graph should not try to - * test any of that graph's visible contents. - */ -@Suppress("MaxLineLength") -class FakeNavHostController : NavHostController(context = mockk()) { - init { - navigatorProvider = TestNavigatorProvider() - navigatorProvider.addNavigator(ComposeNavigator()) - - val state = mockk(relaxed = true) { - every { backStack } returns MutableStateFlow(emptyList()) - } - navigatorProvider.navigators.forEach { (_, navigator) -> - navigator.onAttach(state) - } - } - - /** - * A fake ID that may be used when testing "popping" to a particular graph ID. - */ - val graphId: Int = -1 - - /** - * The current route (or `null` if no initial graph has been set and no navigation has been - * performed). - * - * Note that this represents what the route **would be** if actual navigation were allowed to - * be performed. - */ - private var currentRoute: String? = null - - /** - * Represents the parameters of the last known navigation attempt via a call to [navigate]. - */ - private var lastNavigation: Navigation? = null - - /** - * A mocked-out internal graph. This exists purely to allow for some internal Compose logic - * to complete without incident when rending a nav graph in a Composable. - */ - private val internalGraph = - mockk().apply { - every { id } returns graphId - every { startDestinationId } returns graphId - every { - findNode(resId = graphId) - } returns mockk { every { id } returns graphId } - every { - findNodeComprehensive(resId = any(), lastVisited = any(), searchChildren = any()) - } returns mockk { - every { id } returns graphId - every { arguments } returns emptyMap() - } - } - - override var graph: NavGraph - get() = internalGraph - set(value) { - currentRoute = value.startDestinationRoute - } - - override fun navigate( - request: NavDeepLinkRequest, - navOptions: NavOptions?, - ) { - navigate( - request = request, - navOptions = navOptions, - navigatorExtras = null, - ) - } - - override fun navigate( - request: NavDeepLinkRequest, - navOptions: NavOptions?, - navigatorExtras: Navigator.Extras?, - ) { - lastNavigation = Navigation( - request = request, - navOptions = navOptions, - navigatorExtras = navigatorExtras, - ) - currentRoute = request.uri?.route - } - - /** - * Asserts the [currentRoute] matches the given [route]. - */ - fun assertCurrentRoute(route: String) { - assertEquals(route, currentRoute) - } - - /** - * Asserts multiple aspects of the last navigation to have occurred. - */ - fun assertLastNavigation( - route: String, - navOptions: NavOptions? = null, - navigatorExtras: Navigator.Extras? = null, - ) { - assertEquals(route, currentRoute) - assertEquals(navOptions, lastNavigation?.navOptions) - assertEquals(navigatorExtras, lastNavigation?.navigatorExtras) - } - - /** - * Asserts the [lastNavigation] includes the given [navOptions]. - */ - fun assertLastNavOptions(navOptions: NavOptions?) { - assertEquals(navOptions, lastNavigation?.navOptions) - } - - data class Navigation( - val request: NavDeepLinkRequest, - val navOptions: NavOptions?, - val navigatorExtras: Navigator.Extras?, - ) -} - -/** - * Helper function for converting a [Uri] to a "route" that we'd expect to see when calling - * `NavHostController.currentDestination?.route`. - */ -private val Uri.route: String get() = "${this.path}".removePrefix("/") - -/** - * The following is borrowed directly from the TestNavigatorProvider of the compose testing - * library. - * - * See https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:navigation/navigation-testing/src/main/java/androidx/navigation/testing/TestNavigatorProvider.kt?q=TestNavigatorProvider - */ -@Suppress("MaxLineLength") -private class TestNavigatorProvider : NavigatorProvider() { - - /** - * A [Navigator] that only supports creating destinations. - */ - private val navigator = object : Navigator() { - override fun createDestination() = NavDestination("test") - } - - init { - addNavigator(NavGraphNavigator(this)) - addNavigator("test", navigator) - } - - override fun > getNavigator(name: String): T { - return try { - super.getNavigator(name) - } catch (e: IllegalStateException) { - @Suppress("UNCHECKED_CAST") - navigator as T - } - } -} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt new file mode 100644 index 0000000000..b03cebb0af --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt @@ -0,0 +1,375 @@ +package com.x8bit.bitwarden.ui.platform.feature.rootnav + +import androidx.navigation.navOptions +import com.bitwarden.ui.platform.base.createMockNavHostController +import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData +import com.x8bit.bitwarden.ui.auth.feature.accountsetup.SetupAutofillRoute +import com.x8bit.bitwarden.ui.auth.feature.accountsetup.SetupCompleteRoute +import com.x8bit.bitwarden.ui.auth.feature.accountsetup.SetupUnlockRoute +import com.x8bit.bitwarden.ui.auth.feature.auth.AuthGraphRoute +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationRoute +import com.x8bit.bitwarden.ui.auth.feature.expiredregistrationlink.ExpiredRegistrationLinkRoute +import com.x8bit.bitwarden.ui.auth.feature.resetpassword.ResetPasswordRoute +import com.x8bit.bitwarden.ui.auth.feature.setpassword.SetPasswordRoute +import com.x8bit.bitwarden.ui.auth.feature.trusteddevice.TrustedDeviceGraphRoute +import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.VaultUnlockRoute +import com.x8bit.bitwarden.ui.auth.feature.welcome.WelcomeRoute +import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import com.x8bit.bitwarden.ui.platform.feature.splash.SplashRoute +import com.x8bit.bitwarden.ui.platform.feature.vaultunlocked.VaultUnlockedGraphRoute +import com.x8bit.bitwarden.ui.tools.feature.send.addsend.AddSendRoute +import com.x8bit.bitwarden.ui.tools.feature.send.addsend.ModeType +import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditMode +import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditRoute +import com.x8bit.bitwarden.ui.vault.feature.itemlisting.ItemListingType +import com.x8bit.bitwarden.ui.vault.feature.itemlisting.VaultItemListingRoute +import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset + +class RootNavScreenTest : BaseComposeTest() { + private val mockNavHostController = createMockNavHostController() + private val rootNavStateFlow = MutableStateFlow(RootNavState.Splash) + private val viewModel = mockk { + every { eventFlow } returns emptyFlow() + every { stateFlow } returns rootNavStateFlow + } + + private val expectedNavOptions = navOptions { + // When changing root navigation state, pop everything else off the back stack: + popUpTo(id = mockNavHostController.graph.id) { + inclusive = false + saveState = false + } + launchSingleTop = true + restoreState = false + } + + private var isSplashScreenRemoved: Boolean = false + + @Before + fun setup() { + setContent { + RootNavScreen( + viewModel = viewModel, + navController = mockNavHostController, + onSplashScreenRemoved = { isSplashScreenRemoved = true }, + ) + } + } + + @Test + fun `initial route should be splash`() { + composeTestRule.runOnIdle { + verify { + mockNavHostController.navigate( + route = SplashRoute, + navOptions = expectedNavOptions, + ) + } + } + } + + @Test + fun `when root nav destination changes, navigation should follow`() = runTest { + composeTestRule.runOnIdle { + verify { + mockNavHostController.navigate( + route = SplashRoute, + navOptions = expectedNavOptions, + ) + } + } + assertFalse(isSplashScreenRemoved) + + // Make sure navigating to Auth works as expected: + rootNavStateFlow.value = RootNavState.Auth + composeTestRule.runOnIdle { + verify { + mockNavHostController.navigate( + route = AuthGraphRoute, + navOptions = expectedNavOptions, + ) + } + } + assertTrue(isSplashScreenRemoved) + + // Make sure navigating to Auth with the welcome route works as expected: + rootNavStateFlow.value = RootNavState.AuthWithWelcome + composeTestRule.runOnIdle { + verify { + mockNavHostController.navigate( + route = WelcomeRoute, + navOptions = expectedNavOptions, + ) + } + } + + // Make sure navigating to complete registration route works as expected: + rootNavStateFlow.value = RootNavState.CompleteOngoingRegistration( + email = "example@email.com", + verificationToken = "verificationToken", + fromEmail = true, + timestamp = FIXED_CLOCK.millis(), + ) + composeTestRule.runOnIdle { + verify { + mockNavHostController.navigate( + route = CompleteRegistrationRoute( + emailAddress = "example@email.com", + verificationToken = "verificationToken", + fromEmail = true, + ), + navOptions = null, + ) + } + } + + // Make sure navigating to expired registration link route works as expected: + rootNavStateFlow.value = RootNavState.ExpiredRegistrationLink + composeTestRule.runOnIdle { + verify { + mockNavHostController.navigate(route = ExpiredRegistrationLinkRoute) + } + } + + // Make sure navigating to vault locked works as expected: + rootNavStateFlow.value = RootNavState.VaultLocked + composeTestRule.runOnIdle { + verify { + mockNavHostController.navigate( + route = VaultUnlockRoute.Standard, + navOptions = expectedNavOptions, + ) + } + } + + // Make sure navigating to reset password works as expected: + rootNavStateFlow.value = RootNavState.ResetPassword + composeTestRule.runOnIdle { + verify { + mockNavHostController.navigate( + route = ResetPasswordRoute, + navOptions = expectedNavOptions, + ) + } + } + + // Make sure navigating to set password works as expected: + rootNavStateFlow.value = RootNavState.SetPassword + composeTestRule.runOnIdle { + verify { + mockNavHostController.navigate( + route = SetPasswordRoute, + navOptions = expectedNavOptions, + ) + } + } + + // Make sure navigating to set password works as expected: + rootNavStateFlow.value = RootNavState.TrustedDevice + composeTestRule.runOnIdle { + verify { + mockNavHostController.navigate( + route = TrustedDeviceGraphRoute, + navOptions = expectedNavOptions, + ) + } + } + + // Make sure navigating to vault unlocked works as expected: + rootNavStateFlow.value = RootNavState.VaultUnlocked(activeUserId = "userId") + composeTestRule.runOnIdle { + verify { + mockNavHostController.navigate( + route = VaultUnlockedGraphRoute, + navOptions = expectedNavOptions, + ) + } + } + + // Make sure navigating to vault unlocked for new totp works as expected: + rootNavStateFlow.value = RootNavState.VaultUnlockedForNewTotp(activeUserId = "userId") + composeTestRule.runOnIdle { + verify { + mockNavHostController.navigate( + route = VaultItemListingRoute.AsRoot( + type = ItemListingType.LOGIN, + itemId = null, + ), + navOptions = expectedNavOptions, + ) + } + } + + // Make sure navigating to vault unlocked for new sends works as expected: + rootNavStateFlow.value = RootNavState.VaultUnlockedForNewSend + composeTestRule.runOnIdle { + verify { + mockNavHostController.navigate( + route = AddSendRoute( + type = ModeType.ADD, + editSendId = null, + ), + navOptions = expectedNavOptions, + ) + } + } + + // Make sure navigating to vault unlocked for autofill save works as expected: + rootNavStateFlow.value = RootNavState.VaultUnlockedForAutofillSave( + autofillSaveItem = mockk(), + ) + composeTestRule.runOnIdle { + verify { + mockNavHostController.navigate( + route = VaultUnlockedGraphRoute, + navOptions = expectedNavOptions, + ) + mockNavHostController.navigate( + route = VaultAddEditRoute( + vaultAddEditMode = VaultAddEditMode.ADD, + vaultItemId = null, + vaultItemCipherType = VaultItemCipherType.LOGIN, + selectedFolderId = null, + selectedCollectionId = null, + ), + navOptions = expectedNavOptions, + ) + } + } + + // Make sure navigating to vault unlocked for autofill works as expected: + rootNavStateFlow.value = RootNavState.VaultUnlockedForAutofillSelection( + activeUserId = "userId", + type = AutofillSelectionData.Type.LOGIN, + ) + composeTestRule.runOnIdle { + verify { + mockNavHostController.navigate( + route = VaultUnlockedGraphRoute, + navOptions = expectedNavOptions, + ) + mockNavHostController.navigate( + route = VaultItemListingRoute.AsRoot( + type = ItemListingType.LOGIN, + itemId = null, + ), + navOptions = expectedNavOptions, + ) + } + } + + // Make sure navigating to vault unlocked for CreateCredentialRequest works as expected: + rootNavStateFlow.value = RootNavState.VaultUnlockedForFido2Save( + activeUserId = "activeUserId", + createCredentialRequest = mockk(), + ) + composeTestRule.runOnIdle { + verify { + mockNavHostController.navigate( + route = VaultUnlockedGraphRoute, + navOptions = expectedNavOptions, + ) + mockNavHostController.navigate( + route = VaultItemListingRoute.AsRoot( + type = ItemListingType.LOGIN, + itemId = null, + ), + navOptions = expectedNavOptions, + ) + } + } + + // Make sure navigating to vault unlocked for Fido2Assertion works as expected: + rootNavStateFlow.value = RootNavState.VaultUnlockedForFido2Assertion( + activeUserId = "activeUserId", + fido2CredentialAssertionRequest = mockk(), + ) + composeTestRule.runOnIdle { + verify { + mockNavHostController.navigate( + route = VaultUnlockedGraphRoute, + navOptions = expectedNavOptions, + ) + mockNavHostController.navigate( + route = VaultItemListingRoute.AsRoot( + type = ItemListingType.LOGIN, + itemId = null, + ), + navOptions = expectedNavOptions, + ) + } + } + + // Make sure navigating to vault unlocked for GetCredentialsRequest works as expected: + rootNavStateFlow.value = RootNavState.VaultUnlockedForProviderGetCredentials( + activeUserId = "activeUserId", + getCredentialsRequest = mockk(), + ) + composeTestRule.runOnIdle { + verify { + mockNavHostController.navigate( + route = VaultUnlockedGraphRoute, + navOptions = expectedNavOptions, + ) + mockNavHostController.navigate( + route = VaultItemListingRoute.AsRoot( + type = ItemListingType.LOGIN, + itemId = null, + ), + navOptions = expectedNavOptions, + ) + } + } + + // Make sure navigating to account lock setup works as expected: + rootNavStateFlow.value = RootNavState.OnboardingAccountLockSetup + composeTestRule.runOnIdle { + verify { + mockNavHostController.navigate( + route = SetupUnlockRoute.AsRoot, + navOptions = expectedNavOptions, + ) + } + } + + // Make sure navigating to account autofill setup works as expected: + rootNavStateFlow.value = RootNavState.OnboardingAutoFillSetup + composeTestRule.runOnIdle { + verify { + mockNavHostController.navigate( + route = SetupAutofillRoute.AsRoot, + navOptions = expectedNavOptions, + ) + } + } + + // Make sure navigating to account setup complete works as expected: + rootNavStateFlow.value = RootNavState.OnboardingStepsComplete + composeTestRule.runOnIdle { + verify { + mockNavHostController.navigate( + route = SetupCompleteRoute, + navOptions = expectedNavOptions, + ) + } + } + } +} + +private val FIXED_CLOCK: Clock = Clock.fixed( + Instant.parse("2023-10-27T12:00:00Z"), + ZoneOffset.UTC, +) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt new file mode 100644 index 0000000000..ec02e81887 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt @@ -0,0 +1,236 @@ +package com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar + +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.navigation.navOptions +import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow +import com.bitwarden.ui.platform.base.createMockNavHostController +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import com.x8bit.bitwarden.ui.platform.feature.settings.SettingsGraphRoute +import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorGraphRoute +import com.x8bit.bitwarden.ui.tools.feature.send.SendGraphRoute +import com.x8bit.bitwarden.ui.vault.feature.vault.VaultGraphRoute +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import org.junit.Before +import org.junit.Test + +class VaultUnlockedNavBarScreenTest : BaseComposeTest() { + private val mockNavHostController = createMockNavHostController() + private val mutableEventFlow = bufferedMutableSharedFlow() + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + val viewModel = mockk(relaxed = true) { + every { eventFlow } returns mutableEventFlow + every { stateFlow } returns mutableStateFlow + } + + private val expectedNavOptions = navOptions { + // When changing root navigation state, pop everything else off the back stack: + popUpTo(id = mockNavHostController.graph.id) { + inclusive = false + saveState = true + } + launchSingleTop = true + restoreState = true + } + + @Before + fun setup() { + setContent { + VaultUnlockedNavBarScreen( + viewModel = viewModel, + navController = mockNavHostController, + onNavigateToVaultAddItem = {}, + onNavigateToVaultItem = {}, + onNavigateToVaultEditItem = {}, + onNavigateToAddSend = {}, + onNavigateToEditSend = {}, + onNavigateToDeleteAccount = {}, + onNavigateToExportVault = {}, + onNavigateToFolders = {}, + onNavigateToPasswordHistory = {}, + onNavigateToPendingRequests = {}, + onNavigateToSearchVault = {}, + onNavigateToSearchSend = {}, + onNavigateToSetupAutoFillScreen = {}, + onNavigateToSetupUnlockScreen = {}, + onNavigateToImportLogins = {}, + onNavigateToAddFolderScreen = {}, + onNavigateToFlightRecorder = {}, + onNavigateToRecordedLogs = {}, + ) + } + } + + @Test + fun `vault tab click should send VaultTabClick action`() { + composeTestRule.onNodeWithText(text = "My vault").performClick() + verify { viewModel.trySendAction(VaultUnlockedNavBarAction.VaultTabClick) } + } + + @Test + fun `NavigateToVaultScreen should navigate to VaultScreen`() { + mutableEventFlow.tryEmit( + VaultUnlockedNavBarEvent.NavigateToVaultScreen( + labelRes = R.string.my_vault, + contentDescRes = R.string.my_vault, + ), + ) + composeTestRule.runOnIdle { + verify { + mockNavHostController.navigate( + route = VaultGraphRoute, + navOptions = expectedNavOptions, + ) + } + } + } + + @Test + fun `NavigateToVaultScreen shortcut event should navigate to VaultScreen`() { + mutableEventFlow.tryEmit( + VaultUnlockedNavBarEvent.Shortcut.NavigateToVaultScreen( + labelRes = R.string.my_vault, + contentDescRes = R.string.my_vault, + ), + ) + composeTestRule.runOnIdle { + mockNavHostController.navigate( + route = VaultGraphRoute, + navOptions = expectedNavOptions, + ) + } + } + + @Test + fun `NavigateToSettingsScreen shortcut event should navigate to SettingsScreen`() { + mutableEventFlow.tryEmit(VaultUnlockedNavBarEvent.Shortcut.NavigateToSettingsScreen) + composeTestRule.runOnIdle { + verify { + mockNavHostController.navigate( + route = SettingsGraphRoute, + navOptions = expectedNavOptions, + ) + } + } + } + + @Test + fun `send tab click should send SendTabClick action`() { + composeTestRule.onNodeWithText(text = "Send").performClick() + verify { viewModel.trySendAction(VaultUnlockedNavBarAction.SendTabClick) } + } + + @Test + fun `NavigateToSendScreen should navigate to SendScreen`() { + mutableEventFlow.tryEmit(VaultUnlockedNavBarEvent.NavigateToSendScreen) + composeTestRule.runOnIdle { + mockNavHostController.navigate( + route = SendGraphRoute, + navOptions = expectedNavOptions, + ) + } + } + + @Test + fun `generator tab click should send GeneratorTabClick action`() { + composeTestRule.onNodeWithText(text = "Generator").performClick() + verify { viewModel.trySendAction(VaultUnlockedNavBarAction.GeneratorTabClick) } + } + + @Test + fun `NavigateToGeneratorScreen should navigate to GeneratorScreen`() { + mutableEventFlow.tryEmit(VaultUnlockedNavBarEvent.NavigateToGeneratorScreen) + composeTestRule.runOnIdle { + mockNavHostController.navigate( + route = GeneratorGraphRoute, + navOptions = expectedNavOptions, + ) + } + } + + @Test + fun `NavigateToGeneratorScreen shortcut event should navigate to GeneratorScreen`() { + mutableEventFlow.tryEmit(VaultUnlockedNavBarEvent.Shortcut.NavigateToGeneratorScreen) + composeTestRule.runOnIdle { + mockNavHostController.navigate( + route = GeneratorGraphRoute, + navOptions = expectedNavOptions, + ) + } + } + + @Test + fun `settings tab click should send SendTabClick action`() { + composeTestRule.onNodeWithText(text = "Settings").performClick() + verify { viewModel.trySendAction(VaultUnlockedNavBarAction.SettingsTabClick) } + } + + @Test + fun `NavigateToSettingsScreen should navigate to SettingsScreen`() { + mutableEventFlow.tryEmit(VaultUnlockedNavBarEvent.NavigateToSettingsScreen) + composeTestRule.runOnIdle { + mockNavHostController.navigate( + route = SettingsGraphRoute, + navOptions = expectedNavOptions, + ) + } + } + + @Test + fun `vault nav bar should update according to state`() { + composeTestRule.onNodeWithText(text = "My vault").assertExists() + composeTestRule.onNodeWithText(text = "Vaults").assertDoesNotExist() + + mutableStateFlow.tryEmit( + VaultUnlockedNavBarState( + vaultNavBarLabelRes = R.string.vaults, + vaultNavBarContentDescriptionRes = R.string.vaults, + notificationState = VaultUnlockedNavBarNotificationState( + settingsTabNotificationCount = 0, + ), + ), + ) + + composeTestRule.onNodeWithText(text = "My vault").assertDoesNotExist() + composeTestRule.onNodeWithText(text = "Vaults").assertExists() + } + + @Suppress("MaxLineLength") + @Test + fun `settings tab notification count should update according to state and show correct count`() { + mutableStateFlow.update { + it.copy( + notificationState = VaultUnlockedNavBarNotificationState( + settingsTabNotificationCount = 1, + ), + ) + } + composeTestRule + .onNodeWithText(text = "1", useUnmergedTree = true) + .assertExists() + + mutableStateFlow.update { + it.copy( + notificationState = VaultUnlockedNavBarNotificationState( + settingsTabNotificationCount = 0, + ), + ) + } + composeTestRule + .onNodeWithText(text = "1", useUnmergedTree = true) + .assertDoesNotExist() + } +} + +private val DEFAULT_STATE = VaultUnlockedNavBarState( + vaultNavBarLabelRes = R.string.my_vault, + vaultNavBarContentDescriptionRes = R.string.my_vault, + notificationState = VaultUnlockedNavBarNotificationState( + settingsTabNotificationCount = 0, + ), +) diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index bdec360eef..a09a3c2c2f 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -52,6 +52,7 @@ dependencies { testFixturesImplementation(libs.junit.vintage) testFixturesImplementation(libs.kotlinx.coroutines.test) testFixturesImplementation(libs.square.turbine) + testFixturesImplementation(libs.mockk.mockk) } tasks { diff --git a/ui/src/testFixtures/kotlin/com/bitwarden/ui/platform/base/MockNavHostController.kt b/ui/src/testFixtures/kotlin/com/bitwarden/ui/platform/base/MockNavHostController.kt new file mode 100644 index 0000000000..13c20e4728 --- /dev/null +++ b/ui/src/testFixtures/kotlin/com/bitwarden/ui/platform/base/MockNavHostController.kt @@ -0,0 +1,86 @@ +package com.bitwarden.ui.platform.base + +import android.annotation.SuppressLint +import androidx.navigation.NavDestination +import androidx.navigation.NavGraph +import androidx.navigation.NavGraphNavigator +import androidx.navigation.NavHostController +import androidx.navigation.Navigator +import androidx.navigation.NavigatorProvider +import androidx.navigation.NavigatorState +import androidx.navigation.compose.ComposeNavigator +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow + +/** + * Creates a new instance of a stubbed-out mock [NavHostController] for testing purposes, it is + * primarily designed to be used in the context of verifying calls to [NavHostController.navigate]. + */ +fun createMockNavHostController(): NavHostController = + mockk { + every { graph } returns mockGraph + every { graph = any() } just runs + every { navigatorProvider } returns TestNavigatorProvider() + every { visibleEntries } returns MutableStateFlow(value = emptyList()) + every { currentDestination } returns null + every { currentBackStackEntryFlow } returns emptyFlow() + every { setViewModelStore(viewModelStore = any()) } just runs + every { setLifecycleOwner(owner = any()) } just runs + every { navigate(route = any(), navOptions = any()) } just runs + } + +/** + * The mock graph ID for the mock graph. + */ +private const val MOCK_GRAPH_ID: Int = -1 + +/** + * Creates a new instance of mock [NavGraph] for testing. + */ +private val mockGraph: NavGraph + get() = mockk { + every { id } returns MOCK_GRAPH_ID + every { startDestinationId } returns MOCK_GRAPH_ID + every { + findNode(resId = MOCK_GRAPH_ID) + } returns mockk { every { id } returns MOCK_GRAPH_ID } + } + +/** + * The following is borrowed directly from the TestNavigatorProvider of the compose testing + * library. + * + * See https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:navigation/navigation-testing/src/main/java/androidx/navigation/testing/TestNavigatorProvider.kt?q=TestNavigatorProvider + */ +@SuppressLint("RestrictedApi") +@Suppress("MaxLineLength") +private class TestNavigatorProvider : NavigatorProvider() { + /** + * A [Navigator] that only supports creating destinations. + */ + private val navigator = object : Navigator() { + override fun createDestination() = NavDestination(navigatorName = "test") + } + + init { + addNavigator(navigator = NavGraphNavigator(navigatorProvider = this)) + addNavigator(name = "test", navigator = navigator) + addNavigator(navigator = ComposeNavigator()) + val state = mockk(relaxed = true) { + every { backStack } returns MutableStateFlow(value = emptyList()) + } + navigators.forEach { (_, navigator) -> navigator.onAttach(state = state) } + } + + override fun > getNavigator(name: String): T = + try { + super.getNavigator(name = name) + } catch (_: IllegalStateException) { + @Suppress("UNCHECKED_CAST") + navigator as T + } +}