The Compose ComposeTestRule and the Espresso onView API operate on the same Activity simultaneously. Compose synchronization (idling resources, frame clock awaits) is bridged into Espresso through a single internal IdlingResource named EspressoLink, so the developer does not register anything manually. The trap is threading: Espresso.onView MUST run on the test thread, not from inside rule.runOnIdle { } or rule.runOnUiThread { }. This skill encodes the canonical interop pattern from androidx.compose.foundation's text-field IME tests.
When to use this skill
- The test must operate on an Android
Dialogwindow (which lives in its ownWindowand may not be in the Compose semantic tree). - The test must verify IME (soft keyboard) state — only Espresso's
onView(supportsInputMethods()).perform(click())makes the keyboard appear. - The screen-under-test is a hybrid
Activitywith an Android View hierarchy that contains aComposeView, or vice versa. - The developer asks "how do I use Espresso and Compose in the same test".
- A test calls
Espresso.onView(...)fromrunOnIdle { }and hangs / throws "cannot be run from the main thread".
When NOT to use this skill
- The test is pure Compose; no Android View interactions exist. Use
../../patterns/structuring-a-compose-test/SKILL.md. - The synchronization symptom is "test passes locally, flaky on CI" without any View interop. Use
../../synchronization/synchronizing-with-idle/SKILL.md. - The test is for an
androidx.compose.ui.window.Dialog(Compose dialog) — that DOES surface in the semantics tree viaisDialog()and does NOT need Espresso. Use../../finders/composing-semantics-matchers/SKILL.md.
Prerequisites
androidx.test.espresso:espresso-coreonandroidTestImplementation.androidx.compose.ui:ui-test-junit4onandroidTestImplementationandandroidx.compose.ui:ui-test-manifestondebugImplementation(see../../setup/configuring-test-dependencies/SKILL.md).- An
Activitythat hosts both layers —FragmentActivityis the standard pick; a customActivitydeclared insrc/androidTest/AndroidManifest.xmlalso works. - The test class skeleton from
../../patterns/structuring-a-compose-test/SKILL.md.
Workflow
- 1. Use
createAndroidComposeRule<A>()(v2) for the host Activity. Cited fromcompose/foundation/foundation/src/androidDeviceTest/kotlin/androidx/compose/foundation/textfield/TextFieldFocusCustomDialogTest.kt:60:
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.fragment.app.FragmentActivity
import kotlinx.coroutines.test.StandardTestDispatcher
@get:Rule
val rule = createAndroidComposeRule<FragmentActivity>(StandardTestDispatcher())
-
2. Set Compose content with
rule.setContent { }, then callrule.waitForIdle(). This drains the Compose recomposer/effect queue so the next Espresso interaction sees a stable view tree. -
3. Drive the View layer from the test thread via
Espresso.onView(...). Do NOT wrap this call inrunOnIdleorrunOnUiThread.
import androidx.test.espresso.Espresso
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.matcher.ViewMatchers
rule.waitForIdle()
Espresso.onView(ViewMatchers.supportsInputMethods()).perform(ViewActions.click())
Cited from TextFieldFocusCustomDialogTest.kt:117-119. The full IME test:
@Test
fun keyboardShown_forFieldInAndroidDialog_…() {
val focusRequester = FocusRequester()
val keyboardHelper = KeyboardHelper(rule)
rule.setContent {
wrapContent {
keyboardHelper.initialize()
LaunchedEffect(Unit) { focusRequester.requestFocus() }
BasicTextField(
value = "",
onValueChange = {},
modifier = Modifier.focusRequester(focusRequester),
)
}
}
rule.waitForIdle()
// The dialog's window must be focused for the IME to actually show.
Espresso.onView(ViewMatchers.supportsInputMethods()).perform(ViewActions.click())
keyboardHelper.waitForKeyboardVisibility(visible = true)
}
- 4. Mix Compose and Espresso assertions freely on the test thread. The two APIs run against the same Activity.
rule.onNodeWithTag("submit").performClick()
Espresso.onView(ViewMatchers.withId(R.id.legacy_toast)).check(matches(ViewMatchers.isDisplayed()))
- 5. (Optional) Scope Compose finders to a sub-tree resolved by Espresso. When the Activity contains multiple Compose hosts and a single Espresso
ViewInteractionidentifies which one to interact with, useonRootWithViewInteraction(viewInteraction). This is supported only onAndroidComposeTestRule; non-Android test rules throw"This implementation of ComposeTestRule does not support onRootWithViewInteraction.".
val nestedComposeView = Espresso.onView(ViewMatchers.withId(R.id.nested_compose))
rule.onRootWithViewInteraction(nestedComposeView).onNodeWithTag("submit").performClick()
-
6. Trust the
EspressoLinkbridge. Compose'sIdlingResources — recomposer, snapshot, frame clock — are aggregated byComposeIdlingResourceand surfaced to Espresso through a singleIdlingResourcenamed"Compose-Espresso link". Cited fromcompose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/EspressoLink.android.kt:34-84. The framework registers and unregisters it insidewithStrategy { }(lines 58-73). The developer does NOT needIdlingRegistry.getInstance().register(...)for Compose state. -
7. Conversely, Espresso idling resources are visible to Compose. Anything registered via
IdlingRegistry.getInstance().register(…)is awaited byrule.waitForIdle()because the rule polls Espresso through the same bridge.
Patterns
Pattern: Espresso.onView from runOnIdle deadlocks
// WRONG
@Test
fun submit() {
rule.setContent { /* Compose UI with a button that opens a Dialog */ }
rule.onNodeWithTag("open").performClick()
rule.runOnIdle {
Espresso.onView(ViewMatchers.withId(R.id.confirm)).perform(ViewActions.click())
}
}
// WRONG because: runOnIdle posts to the UI thread. Espresso.onView calls Espresso.onIdle()
// internally, and EspressoLink.runUntilIdle (EspressoLink.android.kt:75-83) explicitly
// throws on UI-thread invocations:
// "Functions that involve synchronization (Assertions, Actions, Synchronization;
// e.g. assertIsSelected(), doClick(), runOnIdle()) cannot be run from the main thread.
// Did you nest such a function inside runOnIdle {}, runOnUiThread {} or setContent {}?"
// RIGHT
@Test
fun submit() {
rule.setContent { /* Compose UI with a button that opens a Dialog */ }
rule.onNodeWithTag("open").performClick()
rule.waitForIdle()
Espresso.onView(ViewMatchers.withId(R.id.confirm)).perform(ViewActions.click())
}
Pattern: dialog focus via Espresso, content via Compose
// WRONG — assuming Compose's IME helper alone is enough
@Test
fun dialogKeyboard() {
rule.setContent { CustomDialog { BasicTextField(/* … */) } }
keyboardHelper.waitForKeyboardVisibility(visible = true) // never visible
}
// WRONG because: Android Dialogs live in a separate Window. Even if the TextField requests
// focus, the soft keyboard does not appear until the dialog window itself takes input
// focus. Only Espresso can drive that — `onView(supportsInputMethods()).perform(click())`.
// RIGHT
@Test
fun dialogKeyboard() {
rule.setContent { CustomDialog { BasicTextField(/* … */) } }
rule.waitForIdle()
Espresso.onView(ViewMatchers.supportsInputMethods()).perform(ViewActions.click())
keyboardHelper.waitForKeyboardVisibility(visible = true)
}
Pattern: createComposeRule() cannot reach an Activity
// WRONG
@get:Rule val rule = createComposeRule(StandardTestDispatcher()) // ComponentActivity host
@Test fun customActivityFlow() {
Espresso.onView(ViewMatchers.withId(R.id.my_activity_view)) // does not exist
.perform(ViewActions.click())
}
// WRONG because: createComposeRule launches the empty ComponentActivity from ui-test-manifest.
// To interact with a custom Activity's view hierarchy, launch that Activity via
// createAndroidComposeRule<MyActivity>().
// RIGHT
@get:Rule val rule = createAndroidComposeRule<MyActivity>(StandardTestDispatcher())
Mandatory rules
- MUST call
Espresso.onView(...)from the test thread, AFTERrule.waitForIdle(). MUST NOT wrap it inrule.runOnIdle { }orrule.runOnUiThread { }— Espresso's idle wait throws on the UI thread (EspressoLink.android.kt:75-83). - MUST use
createAndroidComposeRule<A>(StandardTestDispatcher())for any test that needs to address View IDs of the host Activity.createComposeRule()only launches the bareComponentActivityfromui-test-manifest. - MUST NOT manually register a Compose
IdlingResourcewithIdlingRegistry.getInstance()—EspressoLink(a single bridge resource named"Compose-Espresso link") does this for the framework. Cited atEspressoLink.android.kt:34-84. - PREFERRED: scope Compose interactions to a Compose subtree via
rule.onRootWithViewInteraction(viewInteraction)when an Activity hosts multipleComposeViews. Calling it on a non-AndroidComposeTestRuleimplementation throwsIllegalStateException(the source useserror("This implementation of ComposeTestRule does not support onRootWithViewInteraction.")atComposeTestRuleExt.android.kt:38-42). - PREFERRED: for soft-keyboard tests, follow
rule.setContent { … }→rule.waitForIdle()→Espresso.onView(supportsInputMethods()).perform(click())→keyboardHelper.waitForKeyboardVisibility(visible = true)(TextFieldFocusCustomDialogTest.kt:117-128). - MUST NOT use this skill for
androidx.compose.ui.window.Dialog(Compose dialog) — those surface as semantic nodes matched byisDialog(). See../../finders/composing-semantics-matchers/SKILL.md.
Verification
- The rule is
createAndroidComposeRule<HostActivity>(StandardTestDispatcher())— notcreateComposeRule(). - Every
Espresso.onView(...)call lives at test-method scope, not insiderunOnIdle { }/runOnUiThread { }/setContent { }. -
rule.waitForIdle()is called between the Compose action and the nextEspresso.onView(...)(or vice versa). - No manual
IdlingRegistry.getInstance().register(composeIdlingResource)calls appear — the framework does this throughEspressoLink. - When multiple
ComposeViews exist in one Activity, finders are scoped viarule.onRootWithViewInteraction(...). - No
Thread.sleepis used to "let Espresso settle" —rule.waitForIdle()already polls Espresso through the bridge. See../../synchronization/synchronizing-with-idle/SKILL.md.
References
- Espresso onView API: https://developer.android.com/training/testing/espresso/basics
- Compose-Espresso interop guide: https://developer.android.com/develop/ui/compose/testing#interop-uiautomator-espresso
- IME interop canonical test:
compose/foundation/foundation/src/androidDeviceTest/kotlin/androidx/compose/foundation/textfield/TextFieldFocusCustomDialogTest.kt:57-128 EspressoLinkbridge implementation:compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/EspressoLink.android.kt:34-84onRootWithViewInteractiondefinition:compose/ui/ui-test-junit4/src/androidMain/kotlin/androidx/compose/ui/test/junit4/AndroidComposeTestRule.android.ktcreateAndroidComposeRulev2:compose/ui/ui-test-junit4/src/androidMain/kotlin/androidx/compose/ui/test/junit4/v2/AndroidComposeTestRule.android.kt- KeyboardHelper utility:
compose/foundation/foundation/src/androidDeviceTest/kotlin/androidx/compose/foundation/text/KeyboardHelper.kt