Tim Malseed
11/24/2021, 6:42 AM@Composable
fun RootScreen(navController: NavHostController, viewModel: RootViewModel) {
LaunchedEffect("navigation") {
viewModel.authenticationState
.map { it.toDestination() }
.onEach {
navController.navigate(it.route)
}
.launchIn(this)
}
RootScreen(navController)
}
The test:
@Test
fun testNavigateToHome() {
userManager._authenticationState.value = UserAuthenticationState.Authenticated(
User(null, null),
UserEmployeeState.Pending
)
assert(navController.currentDestination?.route == NavigationDestination.Root.Home.route)
composeTestRule
.onNodeWithTestDescription("Home Screen")
.assertIsDisplayed()
}Tim Malseed
11/24/2021, 6:43 AMuserManager._authenticationState.value = ...
The stacktrace:
java.lang.IllegalStateException: Restarter must be created only during owner's initialization stage
at androidx.savedstate.SavedStateRegistryController.performRestore(SavedStateRegistryController.java:58)
at androidx.navigation.NavBackStackEntry.setMaxLifecycle(NavBackStackEntry.kt:146)
at androidx.navigation.NavController.updateBackStackLifecycle$navigation_runtime_release(NavController.kt:987)
at androidx.navigation.NavController$NavControllerNavigatorState.markTransitionComplete(NavController.kt:359)
at androidx.navigation.compose.ComposeNavigator.onTransitionComplete$navigation_compose_release(ComposeNavigator.kt:71)
at androidx.navigation.compose.NavHostKt$NavHost$4$2$1$invoke$$inlined$onDispose$1.dispose(Effects.kt:486)
at androidx.compose.runtime.DisposableEffectImpl.onForgotten(Effects.kt:85)
at androidx.compose.runtime.CompositionImpl$RememberEventDispatcher.dispatchRememberObservers(Composition.kt:793)
at androidx.compose.runtime.CompositionImpl.dispose(Composition.kt:496)
at androidx.compose.ui.platform.WrappedComposition.dispose(Wrapper.android.kt:171)
at androidx.compose.ui.platform.WrappedComposition.onStateChanged(Wrapper.android.kt:179)
at androidx.lifecycle.LifecycleRegistry$ObserverWithState.dispatchEvent(LifecycleRegistry.java:354)
at androidx.lifecycle.LifecycleRegistry.backwardPass(LifecycleRegistry.java:284)
at androidx.lifecycle.LifecycleRegistry.sync(LifecycleRegistry.java:302)
at androidx.lifecycle.LifecycleRegistry.moveToState(LifecycleRegistry.java:148)
at androidx.lifecycle.LifecycleRegistry.handleLifecycleEvent(LifecycleRegistry.java:134)
at androidx.lifecycle.ReportFragment.dispatch(ReportFragment.java:68)
at androidx.lifecycle.ReportFragment$LifecycleCallbacks.onActivityPreDestroyed(ReportFragment.java:224)
at android.app.Activity.dispatchActivityPreDestroyed(Activity.java:1516)
at android.app.Activity.performDestroy(Activity.java:8312)
at android.app.Instrumentation.callActivityOnDestroy(Instrumentation.java:1364)
at androidx.test.runner.MonitoringInstrumentation.callActivityOnDestroy(MonitoringInstrumentation.java:756)
at android.app.ActivityThread.performDestroyActivity(ActivityThread.java:5374)
at android.app.ActivityThread.handleDestroyActivity(ActivityThread.java:5420)
at android.app.servertransaction.DestroyActivityItem.execute(DestroyActivityItem.java:47)
at android.app.servertransaction.ActivityTransactionItem.execute(ActivityTransactionItem.java:45)
at android.app.servertransaction.TransactionExecutor.executeLifecycleState(TransactionExecutor.java:176)
at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:97)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2210)
at android.os.Handler.dispatchMessage(Handler.java:106)
at android.os.Looper.loopOnce(Looper.java:201)
at android.os.Looper.loop(Looper.java:288)
at android.app.ActivityThread.main(ActivityThread.java:7839)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:548)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1003)Tim Malseed
11/24/2021, 6:45 AMnavController.navigate() isn't called)Tim Malseed
11/24/2021, 6:46 AMcomposeTestRule.waitForIdle() before attempting navigation, but there's no differenceTim Malseed
11/24/2021, 6:58 AMLaunchedEffect?Ian Lake
11/24/2021, 7:13 AMNavController and where your NavHost is? If you're doing an integration test with those, they'd work just fine, but generally you shouldn't be passing your NavController to any screen at all, as per our testing guide: https://developer.android.com/jetpack/compose/navigation#testingIan Lake
11/24/2021, 7:14 AMRootScreen, you don't have any dependency on Navigation at all - you can just verify that your lambda method is calledTim Malseed
11/24/2021, 7:15 AMIan Lake
11/24/2021, 7:16 AMTim Malseed
11/24/2021, 7:17 AMTim Malseed
11/24/2021, 7:18 AMauthenticationState is a StateFlow which can change over time, and the app would navigate to an associated destination in response to those changesTim Malseed
11/24/2021, 7:20 AMYou haven't included where you create yourI'll clear that up momentarily!and where yourNavControllerisNavHost
Ian Lake
11/24/2021, 7:21 AMfun RootScreen(
authenticationState: AuthState,
navigateToLogin: () -> Unit
) {
if (authenticationState is Loading) {
// Show progress
} else if (authenticationState is LoggedIn) {
// Show your logged in state
} else if (authenticationState is LoggedOut) {
// Process the logged out state only once
LaunchedEffect(authenticationState) {
navigateToLogin()
}
}
}Ian Lake
11/24/2021, 7:22 AMcomposable("root") {
val viewModel: RootViewModel = viewModel()
RootScreen(viewModel.authenticationState) {
navController.navigate("login")
}
}Ian Lake
11/24/2021, 7:23 AMRootScreen doesn't depend on ViewModel, doesn't depend on Navigation, and is super simple to testTim Malseed
11/24/2021, 7:28 AMIan Lake
11/24/2021, 7:28 AMIan Lake
11/24/2021, 7:29 AMTim Malseed
11/24/2021, 7:31 AMNow yourI've just gone a little astray, trying to port some iOS code over!doesn't depend on ViewModel, doesn't depend on Navigation, and is super simple to testRootScreen
Ian Lake
11/24/2021, 7:32 AMTim Malseed
11/24/2021, 7:32 AMTim Malseed
11/24/2021, 7:33 AMTim Malseed
11/24/2021, 7:34 AMif (authenticationState is Loading) {
// Show progress
} else if (authenticationState is LoggedIn) {
// Show your logged in state
} else if (authenticationState is LoggedOut) {
// Process the logged out state only once
LaunchedEffect(authenticationState) {
navigateToLogin()
}
}
But not this:
if (authenticationState is Loading) {
// Show progress
} else if (authenticationState is LoggedIn) {
// Process the logged in state only once
LaunchedEffect(authenticationState) {
navigateToHome()
}
} else if (authenticationState is LoggedOut) {
// Process the logged out state only once
LaunchedEffect(authenticationState) {
navigateToLogin()
}
}Tim Malseed
11/24/2021, 7:42 AMYou haven't included where you create your// The NavController:and where yourNavControllerisNavHost
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val userManager = MockUserManager()
val viewModel = RootViewModel(
userManager = userManager
)
setContent {
val navController = rememberNavController()
RootScreen(navController, viewModel)
}
}
// Root Screen
@Composable
fun RootScreen(navController: NavHostController, viewModel: RootViewModel) {
LaunchedEffect("navigation") {
viewModel.authenticationState
.map { it.toDestination() }
.onEach {
navController.navigate(it.route)
}
.launchIn(this)
}
RootScreen(navController)
}
// NavHost
@Composable
fun RootScreen(navController: NavHostController) {
Theme {
NavHost(
navController = navController,
startDestination = NavigationDestination.Root.Loading.route,
modifier = Modifier.fillMaxHeight()
) {
composable(NavigationDestination.Root.Loading.route) {
LoadingScreen()
}
onboardingGraph(navController)
composable(NavigationDestination.Root.Home.route) {
HomeScreen(navController)
}
}
}
}Tim Malseed
11/24/2021, 7:43 AMgenerally you shouldn't be passing yourMaybeto any screen at allNavController
RootScreen is the wrong name for this Composable. But some Composable has to receive the NavController, in order to pass it into the NavHost.Tim Malseed
11/24/2021, 7:44 AMIan Lake
11/24/2021, 7:53 AMTim Malseed
11/24/2021, 11:23 PMTim Malseed
11/25/2021, 12:12 AMnavigate() from a thread other than Main, during the test
From the codelab on testing with Compose:
A third option is to calldirectly — there's one caveat here. Calls tonavController.navigateneed to be made on the UI thread. You can achieve this by usingnavController.navigatewith theCoroutinesthread dispatcher. And since the call needs to happen before you can make an assertion about a new state, it needs to be wrapped in aMaincall.runBlocking