Search code examples
androidkotlinintegration-testingandroid-jetpack-compose

androidx.compose.ui.test.junit4.android.ComposeNotIdleException: Idling resource timed out: possibly due to compose being busy


I'm trying to make a compose app with a different top app bar per screen. My strategy is to pass down a state setter for the data of the app bar through each screen. However passing in an onClick lambda is breaking my tests.

Here's an SSCCE:

// ActionState.kt
class ActionState(val title: String = "", val onClick: () -> Unit = {}) {
    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (javaClass != other?.javaClass) return false

        other as ActionState

        if (title != other.title) return false
        // Commenting out this line fixes the test but breaks the implementation
        if (onClick != other.onClick) return false

        return true
    }

    override fun hashCode(): Int {
        var result = title.hashCode()
        result = 31 * result + onClick.hashCode()
        return result
    }
}

// MainActivity.kt
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            App()
        }
    }
}

@Composable
fun TopBar(actionState: ActionState) {
    TopAppBar(
        title = { Text("App") },
        actions = {
            val contentColor = contentColorFor(MaterialTheme.colors.primarySurface)
            val colors = ButtonDefaults.textButtonColors(contentColor = contentColor)
            TextButton(onClick = actionState.onClick, colors = colors) {
                Text(actionState.title)
            }
        },
    )
}

@Composable
fun ScreenA(setActionState: (ActionState) -> Unit, navigateToScreenB: () -> Unit) {
    setActionState(ActionState("To B", navigateToScreenB))
    Text("Screen A")
}

@Composable
fun ScreenB(setActionState: (ActionState) -> Unit, navigateToScreenA: () -> Unit) {
    setActionState(ActionState("To A", navigateToScreenA))
    Text("Screen B")
}

@Composable
fun App() {
    val navController = rememberNavController()
    val (actionState, setActionState) = remember { mutableStateOf(ActionState()) }
    Scaffold(topBar = { TopBar(actionState = actionState) }) {
        NavHost(navController = navController, startDestination = "a") {
            composable("a") {
                ScreenA(
                    setActionState = setActionState,
                    navigateToScreenB = { navController.navigate("b") },
                )
            }
            composable("b") {
                ScreenB(
                    setActionState = setActionState,
                    navigateToScreenA = { navController.navigate("a") },
                )
            }
        }
    }
}

// Test
class ExampleInstrumentedTest {
    @get:Rule
    val composeTestRule = createAndroidComposeRule<MainActivity>()

    @Test
    fun test() {
        composeTestRule.onNodeWithText("Screen A").assertIsDisplayed()
        composeTestRule.onNodeWithText("To B").performClick()
        composeTestRule.onNodeWithText("Screen B").assertIsDisplayed()
    }
}

The error I'm getting in the test is:

androidx.compose.ui.test.junit4.android.ComposeNotIdleException: Idling resource timed out: possibly due to compose being busy.
IdlingResourceRegistry has the following idling resources registered:
- [busy] androidx.compose.ui.test.junit4.android.ComposeIdlingResource@408168
All registered idling resources: Compose-Espresso link
at androidx.compose.ui.test.junit4.android.EspressoLink_androidKt.rethrowWithMoreInfo(EspressoLink.android.kt:135)
at androidx.compose.ui.test.junit4.android.EspressoLink_androidKt.runEspressoOnIdle(EspressoLink.android.kt:109)
at androidx.compose.ui.test.junit4.android.EspressoLink.runUntilIdle(EspressoLink.android.kt:78)
at androidx.compose.ui.test.junit4.AndroidComposeTestRule.waitForIdle(AndroidComposeTestRule.android.kt:289)
at androidx.compose.ui.test.junit4.AndroidComposeTestRule.access$waitForIdle(AndroidComposeTestRule.android.kt:155)
at androidx.compose.ui.test.junit4.AndroidComposeTestRule$AndroidTestOwner.getRoots(AndroidComposeTestRule.android.kt:441)
at androidx.compose.ui.test.TestContext.getAllSemanticsNodes$ui_test_release(TestOwner.kt:95)
at androidx.compose.ui.test.SemanticsNodeInteraction.fetchSemanticsNodes$ui_test_release(SemanticsNodeInteraction.kt:79)
at androidx.compose.ui.test.SemanticsNodeInteraction.fetchOneOrDie(SemanticsNodeInteraction.kt:145)
at androidx.compose.ui.test.SemanticsNodeInteraction.fetchSemanticsNode(SemanticsNodeInteraction.kt:96)
at androidx.compose.ui.test.AndroidAssertions_androidKt.checkIsDisplayed(AndroidAssertions.android.kt:29)
at androidx.compose.ui.test.AssertionsKt.assertIsDisplayed(Assertions.kt:33)
at ogbe.eva.topbarbug.ExampleInstrumentedTest.test(ExampleInstrumentedTest.kt:16)
... 33 trimmed
Caused by: androidx.test.espresso.IdlingResourceTimeoutException: Wait for [Compose-Espresso link] to become idle timed out
at androidx.test.espresso.IdlingPolicy.handleTimeout(IdlingPolicy.java:4)
at androidx.test.espresso.base.UiControllerImpl$5.resourcesHaveTimedOut(UiControllerImpl.java:1)
at androidx.test.espresso.base.IdlingResourceRegistry$Dispatcher.handleTimeout(IdlingResourceRegistry.java:4)
at androidx.test.espresso.base.IdlingResourceRegistry$Dispatcher.handleMessage(IdlingResourceRegistry.java:6)
at android.os.Handler.dispatchMessage(Handler.java:102)
at androidx.test.espresso.base.Interrogator.loopAndInterrogate(Interrogator.java:14)
at androidx.test.espresso.base.UiControllerImpl.loopUntil(UiControllerImpl.java:8)
at androidx.test.espresso.base.UiControllerImpl.loopMainThreadUntilIdle(UiControllerImpl.java:17)
at androidx.test.espresso.Espresso$1.run(Espresso.java:1)
at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:462)
at java.util.concurrent.FutureTask.run(FutureTask.java:266)
at android.os.Handler.handleCallback(Handler.java:938)
at android.os.Handler.dispatchMessage(Handler.java:99)
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)

If I remove the onClick lambda from the equality test then the test passes. But then the action doesn't work in the app when I navigate away from the screen and navigate back. I think it ends up using stale data.

I'm thinking the problem has to do with the equality check in either remember or mutableStateOf but I don't know enough about those methods to really understand what's going on.

How do I prevent the tests from idling? Alternatively, is there a better way to implement per screen top app bar state?


Solution

  • I moved the scaffold into each separate screen. I haven't noticed the top app bar flickering, so they might have fixed the bug that used to cause that.