mirror of
https://github.com/bitwarden/android.git
synced 2024-11-22 01:16:02 +03:00
BIT-168: Add FakeNavHostController and tests for RootNavScreen (#46)
Co-authored-by: Andrew Haisting <ahaisting@livefront.com>
This commit is contained in:
parent
4229918d74
commit
a358408ea7
3 changed files with 252 additions and 1 deletions
|
@ -5,6 +5,7 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.NavDestination
|
import androidx.navigation.NavDestination
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.navOptions
|
import androidx.navigation.navOptions
|
||||||
|
@ -24,8 +25,8 @@ import com.x8bit.bitwarden.ui.platform.feature.vaultunlocked.vaultUnlockedDestin
|
||||||
@Composable
|
@Composable
|
||||||
fun RootNavScreen(
|
fun RootNavScreen(
|
||||||
viewModel: RootNavViewModel = hiltViewModel(),
|
viewModel: RootNavViewModel = hiltViewModel(),
|
||||||
|
navController: NavHostController = rememberNavController(),
|
||||||
) {
|
) {
|
||||||
val navController = rememberNavController()
|
|
||||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
NavHost(
|
NavHost(
|
||||||
|
|
|
@ -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<NavGraph>().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<NavDestination>() {
|
||||||
|
override fun createDestination() = NavDestination("test")
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
addNavigator(NavGraphNavigator(this))
|
||||||
|
addNavigator("test", navigator)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun <T : Navigator<out NavDestination>> getNavigator(name: String): T {
|
||||||
|
return try {
|
||||||
|
super.getNavigator(name)
|
||||||
|
} catch (e: IllegalStateException) {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
navigator as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<RootNavViewModel>(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>(RootNavState.Splash)
|
||||||
|
val viewModel = mockk<RootNavViewModel>(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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue