Search code examples
android-jetpack-composeandroid-espressoandroid-jetpack-navigation

How to tell the composeTestRule to wait for the navhost transition?


I'm trying to write an integration test for an Android application entirely written in Compose that has a single Activity and uses the Compose Navigation to change the screen content.

I managed to properly interact and test the first screen that is shown by the navigation graph but, as soon as I navigate to a new destination, the test fails because it does not wait for the NavHost to load the new content.

@RunWith(AndroidJUnit4::class)
class MainActivityTest {
    @get:Rule
    val composeTestRule = createAndroidComposeRule<MainActivity>()

    @Test
    fun appStartsWithoutCrashing() {
        composeTestRule.apply {
            // Check Switch
            onNodeWithTag(FirstScreen.CONSENT_SWITCH)
                .assertIsDisplayed()
                .assertIsOff()
                .performClick()
                .assertIsOn()

            // Click accept button
            onNodeWithTag(FirstScreen.ACCEPT_BUTTON)
                .assertIsDisplayed()
                .performClick()

            // Check we are inside the second screen
            onNodeWithTag(SecondScreen.USERNAME_TEXT_FIELD)
                .assertIsDisplayed()
        }
    }
}

I'm sure that is a timing issue because if I add a Thread.sleep(500) before the onNodeWithTag(SecondScreen.USERNAME_TEXT_FIELD).assertIsDisplayed(), the test is successful. But I would like to avoid Thread.sleep()s in my code.

Is there a better way to tell the composeTestRule to wait for the NavHost to load the new content before executing the assertIsDisplayed()?

PS I know that would be better to test the Composables in isolation, but I really want to simulate the user input on the App using Espresso and not only test the Composable behavior.


Solution

  • As suggested in this very informative blog article, waitUntil can be used to wait until the node with the right tag is shown:

                // Waiting for the new destination to be shown
                waitUntil {
                    composeTestRule
                        .onAllNodesWithTag(LogInTestTags.USERNAME_TEXT_FIELD)
                        .fetchSemanticsNodes().size == 1
                }
    

    Or, after adding some sugar:

    @RunWith(AndroidJUnit4::class)
    class MainActivityTest {
        @get:Rule
        val composeTestRule = createAndroidComposeRule<MainActivity>()
    
        @Test
        fun appStartsWithoutCrashing() {
            composeTestRule.apply {
                // Check Switch
                onNodeWithTag(FirstScreen.CONSENT_SWITCH)
                    .assertIsDisplayed()
                    .assertIsOff()
                    .performClick()
                    .assertIsOn()
    
                // Click accept button
                onNodeWithTag(FirstScreen.ACCEPT_BUTTON)
                    .assertIsDisplayed()
                    .performClick()
    
                // Waiting for the new destination to be shown
                waitUntilExists(hasTestTag(SecondScreen.USERNAME_TEXT_FIELD))
    
                // Check we are inside the second screen
                onNodeWithTag(SecondScreen.USERNAME_TEXT_FIELD)
                    .assertIsDisplayed()
            }
        }
    }
    
    private const val WAIT_UNTIL_TIMEOUT = 1_000L
    
    fun ComposeContentTestRule.waitUntilNodeCount(
        matcher: SemanticsMatcher,
        count: Int,
        timeoutMillis: Long = WAIT_UNTIL_TIMEOUT
    ) {
        waitUntil(timeoutMillis) {
            onAllNodes(matcher).fetchSemanticsNodes().size == count
        }
    }
    
    fun ComposeContentTestRule.waitUntilExists(
        matcher: SemanticsMatcher,
        timeoutMillis: Long = WAIT_UNTIL_TIMEOUT
    ) = waitUntilNodeCount(matcher, 1, timeoutMillis)
    
    fun ComposeContentTestRule.waitUntilDoesNotExist(
        matcher: SemanticsMatcher,
        timeoutMillis: Long = WAIT_UNTIL_TIMEOUT
    ) = waitUntilNodeCount(matcher, 0, timeoutMillis)