Edit: Solved by disabling ‘parallel run’ on tests ...
# compose
t
Edit: Solved by disabling ‘parallel run’ on tests in AS Hey all, another Instrumented Testing crash I could use some help with.. It seems that I get this crash depending on the order that my tests are executed.. Stacktrace:
Copy code
java.lang.NullPointerException
        at androidx.compose.ui.layout.SubcomposeLayoutState.disposeCurrentNodes$ui_release(SubcomposeLayout.kt:386)
...
Composable code:
Copy code
@Composable
fun HomeScreen(viewModel: HomeViewModel = hiltViewModel(), onNavigate: (destination: NavigationDestination) -> Unit = {}) {
    val authenticationState by viewModel.authenticationState.collectAsState()
    when (authenticationState) {
        is UserAuthenticationState.Undetermined -> {
            LoadingScreen()
        }
        is UserAuthenticationState.Unauthenticated -> {
            LaunchedEffect(authenticationState) {
                onNavigate(NavigationDestination.Root.Onboarding)
            }
        }
        is UserAuthenticationState.Authenticated -> {
            HomeScreen()
        }
    }
}

@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:
Copy code
@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, one for the NavHost, and one for the Compose UI:
NavHost tests:
Copy code
@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)
    }

    @Test
    fun navigateToCoachScreen() {
        runBlocking {
            withContext(Dispatchers.Main) {
                navController.navigate(NavigationDestination.Home.Coach.route)
            }
        }

        assert(navController.currentDestination?.route == NavigationDestination.Home.Coach.route)
    }

    ...
UI Tests:
Copy code
@HiltAndroidTest
class HomeScreenTest {

    @get:Rule(order = 0)
    var hiltRule = HiltAndroidRule(this)

    @get:Rule(order = 1)
    val composeTestRule = createAndroidComposeRule<MainActivity>()

    @Before
    fun setup() {
        hiltRule.inject()

        composeTestRule.setContent {
            HomeScreen()
        }
    }

    @Test
    fun bottomTabsAreDisplayed() {
        composeTestRule.onNodeWithTestDescription("coach").assertIsDisplayed()
        composeTestRule.onNodeWithTestDescription("discover").assertIsDisplayed()
        composeTestRule.onNodeWithTestDescription("library").assertIsDisplayed()
        composeTestRule.onNodeWithTestDescription("you").assertIsDisplayed()
        composeTestRule.onNodeWithTestDescription("search").assertIsDisplayed()
    }

    @Test
    fun initialDestinationIsCoach() {
        composeTestRule
            .onNodeWithTestDescription("Coach Screen")
            .assertIsDisplayed()
    }

    ...
If I run the 'UI' tests only, they all run just fine.
It seems the crash only happens when the UI tests are run after the NavHost tests
So, maybe some state is lingering between tests?
Full Stack Trace:
Copy code
E/AndroidJUnitRunner: An unhandled exception was thrown by the app.
    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)
The left shows the tests passing independently. The right shows the same test failing after the other tests run
Interestingly, if I out-comment the
setContent()
call in
MainActivity
(the Activity being used by the
createAndroidComposeRule
, due to the presence of Hilt), the exception moves elsewhere:
Copy code
java.lang.NullPointerException: Attempt to invoke virtual method 'androidx.compose.ui.graphics.vector.ImageVector com.myapp.android.ui.components.common.NavigationDestination$Home.getImage()' on a null object reference
	at com.myapp.android.ui.components.home.HomeScreenKt$BottomNav$2$1$2.invoke(HomeScreen.kt:90)
	at com.myapp.android.ui.components.home.HomeScreenKt$BottomNav$2$1$2.invoke(HomeScreen.kt:88)
	at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.jvm.kt:107)
	at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.jvm.kt:34)
Which corresponds to this line:
imageVector = screen.image,
In BottomNav:
Copy code
@Composable
fun BottomNav(currentDestination: NavDestination?, onItemClick: (NavigationDestination) -> Unit = {}) {
    BottomNavigation(
        modifier = Modifier,
        backgroundColor = MaterialColors.surface
    ) {
        NavigationDestination.Home.all.forEach { screen ->
            val selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true

            BottomNavigationItem(
                selected = selected,
                alwaysShowLabel = selected,
                icon = {
                    Icon(
                        imageVector = screen.image,
                        contentDescription = stringResource(id = screen.titleResId),
                        Modifier.semantics { testDescription = screen.route }
                    )
                },
                label = { Text(text = stringResource(id = screen.titleResId)) },
                selectedContentColor = MaterialColors.onBackground,
                unselectedContentColor = MaterialColors.onBackground.copy(alpha = ContentAlpha.medium),
                onClick = { onItemClick(screen) }
            )
        }
    }
}
Copy code
sealed class Home(
        override val route: String,
        @StringRes val titleResId: Int,
        val image: ImageVector
    ) : NavigationDestination(route) {

        object Coach : Home(route = "coach", titleResId = R.string.home_tab_coach, image = Icons.Outlined.EmojiPeople)
        object Discover : Home(route = "discover", titleResId = R.string.home_tab_discover, image = Icons.Outlined.Explore)
        object Library : Home(route = "library", titleResId = R.string.home_tab_library, image = Icons.Outlined.GridView)
        object You : Home(route = "you", titleResId = R.string.home_tab_you, image = Icons.Outlined.PersonOutline)
        object Search : Home(route = "search", titleResId = R.string.home_tab_search, image = Icons.Outlined.Search)

        companion object {
            val all: List<Home> = listOf(Coach, Discover, Library, You, Search)
        }
    }
Edit: This issue only occurs when running the tests inside of Android Studio!
Oh, and it's fixed by disabling 'parallel run'
😭
z
Running parallel instrumentation tests seems risky in general, since most of the testing infrastructure assumes only one app is active at a time. I'm surprised this would work even without compose.
t
Yeah, I wonder if maybe it shouldn’t be the default in Android Studio
Obviously this is my own fault for not understanding the tools. But do you think this could be an issue raised against AS?
👍 1
z
I didn’t realize it was the default – i didn’t think i’d ever seen it try to run parallel instrumentation tests
t
Yeah.. I didn’t manually change that setting, but who knows
Interestingly, and frustratingly, I’m now getting this issue on the commandline via
connectedAndroidTest
I’m curious whether those are parallelized, but can’t seem to figure that out