https://kotlinlang.org logo
Title
t

Tim Malseed

11/25/2021, 3:48 AM
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:
java.lang.NullPointerException
        at androidx.compose.ui.layout.SubcomposeLayoutState.disposeCurrentNodes$ui_release(SubcomposeLayout.kt:386)
...
Composable 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:
@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:
@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:
@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:
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:
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:
@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) }
            )
        }
    }
}
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

Zach Klippenstein (he/him) [MOD]

11/25/2021, 7:57 PM
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

Tim Malseed

11/25/2021, 10:10 PM
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

Zach Klippenstein (he/him) [MOD]

11/29/2021, 1:14 AM
I didn’t realize it was the default – i didn’t think i’d ever seen it try to run parallel instrumentation tests
t

Tim Malseed

11/29/2021, 1:31 AM
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