From a358408ea72c438ccbcbca41969f40ae1792a2f6 Mon Sep 17 00:00:00 2001 From: Brian Yencho Date: Wed, 13 Sep 2023 14:41:56 -0500 Subject: [PATCH] BIT-168: Add FakeNavHostController and tests for RootNavScreen (#46) Co-authored-by: Andrew Haisting --- .../platform/feature/rootnav/RootNavScreen.kt | 3 +- .../ui/platform/base/FakeNavHostController.kt | 172 ++++++++++++++++++ .../feature/rootnav/RootNavScreenTest.kt | 78 ++++++++ 3 files changed, 252 insertions(+), 1 deletion(-) create 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 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 2eb7fcc40..fcb87afa2 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 @@ -5,6 +5,7 @@ import androidx.compose.runtime.getValue import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavDestination +import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController import androidx.navigation.navOptions @@ -24,8 +25,8 @@ import com.x8bit.bitwarden.ui.platform.feature.vaultunlocked.vaultUnlockedDestin @Composable fun RootNavScreen( viewModel: RootNavViewModel = hiltViewModel(), + navController: NavHostController = rememberNavController(), ) { - val navController = rememberNavController() val state by viewModel.stateFlow.collectAsStateWithLifecycle() NavHost( 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 new file mode 100644 index 000000000..d3bfc3c37 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/FakeNavHostController.kt @@ -0,0 +1,172 @@ +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.compose.ComposeNavigator +import io.mockk.every +import io.mockk.mockk +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()) + } + + /** + * 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 + } + + 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(currentRoute, route) + } + + /** + * Asserts multiple aspects of the last navigation to have occurred. + */ + fun assertLastNavigation( + route: String, + navOptions: NavOptions? = null, + navigatorExtras: Navigator.Extras? = null, + ) { + assertEquals(currentRoute, route) + assertEquals(lastNavigation?.navOptions, navOptions) + assertEquals(lastNavigation?.navigatorExtras, navigatorExtras) + } + + /** + * Asserts the [lastNavigation] includes the given [navOptions]. + */ + fun assertLastNavOptions(navOptions: NavOptions?) { + assertEquals(lastNavigation?.navOptions, 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 000000000..efe9ff6d6 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt @@ -0,0 +1,78 @@ +package com.x8bit.bitwarden.ui.platform.feature.rootnav + +import androidx.navigation.navOptions +import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import com.x8bit.bitwarden.ui.platform.base.FakeNavHostController +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class RootNavScreenTest : BaseComposeTest() { + private val fakeNavHostController = FakeNavHostController() + + private val expectedNavOptions = navOptions { + // When changing root navigation state, pop everything else off the back stack: + popUpTo(fakeNavHostController.graphId) { + inclusive = false + saveState = true + } + launchSingleTop = true + restoreState = true + } + + @Test + fun `initial route should be splash`() { + val viewModel = mockk(relaxed = true) { + every { eventFlow } returns emptyFlow() + every { stateFlow } returns MutableStateFlow(RootNavState.Splash) + } + composeTestRule.setContent { + RootNavScreen( + viewModel = viewModel, + navController = fakeNavHostController, + ) + } + composeTestRule.runOnIdle { + fakeNavHostController.assertCurrentRoute("splash") + } + } + + @Test + fun `when root nav destination changes, navigation should follow`() = runTest { + val rootNavStateFlow = MutableStateFlow(RootNavState.Splash) + val viewModel = mockk(relaxed = true) { + every { eventFlow } returns emptyFlow() + every { stateFlow } returns rootNavStateFlow + } + composeTestRule.setContent { + RootNavScreen( + viewModel = viewModel, + navController = fakeNavHostController, + ) + } + composeTestRule.runOnIdle { + fakeNavHostController.assertCurrentRoute("splash") + } + + // Make sure navigating to Auth works as expected: + rootNavStateFlow.value = RootNavState.Auth + composeTestRule.runOnIdle { + fakeNavHostController.assertLastNavigation( + route = "auth", + navOptions = expectedNavOptions, + ) + } + + // Make sure navigating to vault unlocked works as expected: + rootNavStateFlow.value = RootNavState.VaultUnlocked + composeTestRule.runOnIdle { + fakeNavHostController.assertLastNavigation( + route = "VaultUnlocked", + navOptions = expectedNavOptions, + ) + } + } +}