Tim Malseed
11/30/2021, 12:27 PMTim Malseed
11/30/2021, 12:33 PMjava.lang.NullPointerException
at androidx.compose.ui.layout.SubcomposeLayoutState.disposeCurrentNodes$ui_release(SubcomposeLayout.kt:386)
Here are the Composable functions being rendered (and tested) in this example:
Home Screen
@Composable
fun HomeScreen(
viewModel: HomeViewModel = hiltViewModel(),
onNavigate: (destination: NavigationDestination) -> Unit = {}
) {
if (viewModel.isAuthenticated) {
HomeScreen()
} else {
LaunchedEffect(viewModel.isAuthenticated) {
onNavigate(NavigationDestination.Root.Onboarding)
}
}
}
@Composable
fun HomeScreen() {
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
Scaffold(
bottomBar = {
BottomAppBar(
modifier = Modifier,
backgroundColor = MaterialColors.background
) {
BottomNav(currentDestination) { screen ->
navController.navigate(screen.route)
}
}
},
modifier = Modifier.semantics { testDescription = "Home Screen" }
) { padding ->
HomeNavHost(
navController = navController,
modifier = Modifier.padding(padding)
)
}
}
HomeNavHost
@Composable
fun HomeNavHost(navController: NavHostController, modifier: Modifier = Modifier) {
NavHost(
navController = navController,
startDestination = NavigationDestination.Home.Coach.route,
modifier = modifier
) {
composable(NavigationDestination.Home.Coach.route) {
CoachScreen()
}
composable(NavigationDestination.Home.Discover.route) {
DiscoverScreen()
}
composable(NavigationDestination.Home.Library.route) {
LibraryScreen()
}
composable(NavigationDestination.Home.You.route) {
YouScreen()
}
composable(NavigationDestination.Home.Search.route) {
SearchScreen()
}
}
}
I have two tests, HomeNavHostTest
, for testing the NavHost in isolation, and HomeScreenTest
.
The crash occurs when HomeScreenTest
runs after HomeNavHostTest
HomeNavHostTest
@HiltAndroidTest
class HomeNavHostTest {
@get:Rule(order = 0)
var hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<MainActivity>()
private lateinit var navController: NavHostController
@Before
fun setup() {
hiltRule.inject()
composeTestRule.setContent {
navController = rememberNavController()
HomeNavHost(navController = navController)
}
}
@Test
fun initialDestinationIsCoach() {
assert(navController.currentDestination?.route == NavigationDestination.Home.Coach.route)
}
{
HomeScreenTest
@ExperimentalCoroutinesApi
@HiltAndroidTest
class HomeScreenTest {
@get:Rule(order = 0)
var hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<MainActivity>()
@Inject
lateinit var userManager: FakeUserManager
private var navigationDestination: NavigationDestination? = null
@Before
fun setup() {
hiltRule.inject()
}
fun renderScreen(){
composeTestRule.setContent {
HomeScreen() {
navigationDestination = it
}
}
}
@Test
fun bottomTabsAreDisplayed() {
// When the authentication state is 'Authenticated'
userManager.onIsAuthenticated = { true }
renderScreen()
// Then the bottom tabs are displayed
composeTestRule.onNodeWithTestDescription("coach").assertIsDisplayed()
composeTestRule.onNodeWithTestDescription("discover").assertIsDisplayed()
composeTestRule.onNodeWithTestDescription("library").assertIsDisplayed()
composeTestRule.onNodeWithTestDescription("you").assertIsDisplayed()
composeTestRule.onNodeWithTestDescription("search").assertIsDisplayed()
}
}
Full StackTrace:
java.lang.NullPointerException
at androidx.compose.ui.layout.SubcomposeLayoutState.disposeCurrentNodes$ui_release(SubcomposeLayout.kt:386)
at androidx.compose.ui.layout.SubcomposeLayoutKt$SubcomposeLayout$3$invoke$$inlined$onDispose$1.dispose(Effects.kt:484)
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/30/2021, 12:35 PMViewModel
not being recreated or something?Zach Klippenstein (he/him) [MOD]
11/30/2021, 5:05 PMTim Malseed
12/01/2021, 12:01 AMTim Malseed
12/01/2021, 11:17 AMMainActivity.setContent()
during testing, and is probably just user error.
Because I’m using Hilt, and I need to inject dependencies in test, I need the tests to run against a @AndroidEntryPoint
annotated Activity. So, I decided to just use my existing MainActivity
@get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<MainActivity>()
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
HomeScreen()
}
}
}
Then, during the Composable test, I need to set some ViewModel state, before calling `setContent()`:
@Test
fun when_undetermined_then_showLoadingScreen() {
// When the authentication state is 'Unauthenticated'
userManager.onUpdateAuthenticationState = { UserAuthenticationState.Undetermined }
composeTestRule.setContent {
HomeScreen() {
navigationDestination = it
}
}
// Then the initial screen is 'loading'
composeTestRule
.onNodeWithTestDescription("Loading Screen")
.assertIsDisplayed()
}
The problem is, MainActivity
already calls setContent()
. And the createAndroidComposeRule
starts the MainActivity
. Then, during the test, setContent()
is called again. I’m not quite sure what the implications are of this, when combined with HiltViewModel and Singleton dependencies, but it’s not that surprising that the tests are tripping over themselves.Tim Malseed
12/01/2021, 11:18 AMMainActivity
, which calls setContent()
and the test also calls setContent()
which leads to a bunch of weird bugsZach Klippenstein (he/him) [MOD]
12/01/2021, 6:30 PM