Hi all, I’ve got a bug in testing Compose navigat...
# compose
t
Hi all, I’ve got a bug in testing Compose navigation that I’ve been trying to solve over the course of the past week. I suspect there’s a bug in the testing framework. If someone could help confirm that, I’d be happy to file a bug report. I’m getting a crash when one of my tests runs after another test. It seems that there’s some lingering state or something going on. Both tests can be run in isolation and pass. But one test fails if run after the other. Code in thread
The error:
Copy code
java.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
Copy code
@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
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,
HomeNavHostTest
, for testing the NavHost in isolation, and
HomeScreenTest
. The crash occurs when
HomeScreenTest
runs after
HomeNavHostTest
HomeNavHostTest
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)
    }

{
HomeScreenTest
Copy code
@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:
Copy code
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)
Perhaps it’s unusual to have two sets of tests that execute parts of the same Composable, and there’s an issue with the
ViewModel
not being recreated or something?
z
That crash is definitely a bug. I don’t think there’s anything particularly weird about your code, but even if there was, that exception is not helpful. Could you file a bug with all this context?
👍 1
t
OK, so I think I found the cause of the problem (unconfirmed, but likely), and it seems to be my mistake (not surprised!) I believe the problem occurs due to multiple calls to 
MainActivity.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
Copy code
@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()`:
Copy code
@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.
tl;dr Tests run against
MainActivity
, which calls
setContent()
and the test also calls
setContent()
which leads to a bunch of weird bugs
z
oh, yea i don’t think that’s supported – but we should throw a better error message still
191 Views