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 yourNavController
isNavHost
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 yourNavController
isNavHost
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.navigate
need to be made on the UI thread. You can achieve this by usingnavController.navigate
with theCoroutines
thread dispatcher. And since the call needs to happen before you can make an assertion about a new state, it needs to be wrapped in aMain
call.runBlocking