关于 testing-with-espresso-interop
这项测试技能介绍如何在同一个 Android Activity 中结合 Jetpack Compose 与 Espresso。重点涵盖同步、测试线程规则,以及对 Android View、对话框和软键盘的交互。
主要功能
- 01组合使用 Compose 与 Espresso API
- 02桥接 Compose 与 Espresso 空闲状态
- 03交互 Android 对话框和输入法
- 04将查找器限定到 Compose 宿主
适用场景
- 01在 Compose 测试中操作 Android 对话框
- 02验证测试中的软键盘行为
- 03测试 View 与 Compose 混合界面
- 04排查在 UI 线程调用 Espresso 的问题
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ skydoves/ android-testing-skills --skill testing-with-espresso-interop安装后在 Claude Code 中输入/testing-with-espresso-interop确认已加载。
详细介绍
使用 Espresso 互操作测试——一个 Activity、两个测试框架、一个桥接器
Compose ComposeTestRule 和 Espresso onView API 可同时操作同一个 Activity。Compose 同步机制(空闲资源、等待帧时钟)通过名为 EspressoLink 的单个内部 IdlingResource 桥接到 Espresso,因此开发者无需手动注册任何内容。关键在于线程:Espresso.onView 必须在测试线程上运行,不能在 rule.runOnIdle { } 或 rule.runOnUiThread { } 内调用。本技能收录了 androidx.compose.foundation 文本框 IME 测试中的规范互操作模式。
何时使用此技能
- 测试必须操作 Android
Dialog窗口(它位于独立的Window中,可能不在 Compose 语义树内)。 - 测试必须验证 IME(软键盘)状态——只有 Espresso 的
onView(supportsInputMethods()).perform(click())才会显示键盘。 - 被测屏幕是混合型
Activity,其 Android View 层级中包含ComposeView,反之亦然。 - 开发者询问“如何在同一个测试中使用 Espresso 和 Compose”。
- 测试在
runOnIdle { }中调用Espresso.onView(...),随后卡住或抛出“cannot be run from the main thread”。
何时不要使用此技能
- 测试完全使用 Compose;不存在 Android View 交互。请使用
../../patterns/structuring-a-compose-test/SKILL.md。 - 同步问题是“测试在本地通过,但在 CI 上不稳定”,且没有任何 View 互操作。请使用
../../synchronization/synchronizing-with-idle/SKILL.md。 - 测试针对
androidx.compose.ui.window.Dialog(Compose 对话框)——它确实会通过isDialog()出现在语义树中,无需使用 Espresso。请使用../../finders/composing-semantics-matchers/SKILL.md。
先决条件
- 在
androidTestImplementation中添加androidx.test.espresso:espresso-core。 - 在
androidTestImplementation中添加androidx.compose.ui:ui-test-junit4,并在debugImplementation中添加androidx.compose.ui:ui-test-manifest(参见../../setup/configuring-test-dependencies/SKILL.md)。 - 一个承载这两个层的
Activity——通常选用FragmentActivity;在src/androidTest/AndroidManifest.xml中声明自定义Activity也可行。 - 来自
../../patterns/structuring-a-compose-test/SKILL.md的测试类框架。
工作流
- 1. 使用
createAndroidComposeRule<A>()(v2)作为宿主 Activity。 引自compose/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. 使用
rule.setContent { }设置 Compose 内容,然后调用rule.waitForIdle()。 这会清空 Compose recomposer/effect 队列,确保后续 Espresso 交互看到稳定的视图树。 -
3. 在测试线程上通过
Espresso.onView(...)操作 View 层。 不要将此调用包装在runOnIdle或runOnUiThread中。
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())
引自 TextFieldFocusCustomDialogTest.kt:117-119。完整的 IME 测试:
@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. 在测试线程上自由混用 Compose 和 Espresso 断言。 这两个 API 操作的是同一个 Activity。
rule.onNodeWithTag("submit").performClick()
Espresso.onView(ViewMatchers.withId(R.id.legacy_toast)).check(matches(ViewMatchers.isDisplayed()))
- 5.(可选)将 Compose 查找器限定到由 Espresso 解析出的子树。 当 Activity 包含多个 Compose 宿主,且单个 Espresso
ViewInteraction可确定要与哪个宿主交互时,使用onRootWithViewInteraction(viewInteraction)。此功能仅受AndroidComposeTestRule支持;非 Android 测试规则会抛出"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. 信任
EspressoLink桥接机制。 Compose 的IdlingResource——recomposer、snapshot、frame clock——由ComposeIdlingResource汇总,并通过名为"Compose-Espresso link"的单个IdlingResource提供给 Espresso。引自compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/EspressoLink.android.kt:34-84。框架会在withStrategy { }中注册和注销该资源(第 58-73 行)。开发者无需为 Compose 状态调用IdlingRegistry.getInstance().register(...)。 -
7. 反过来,Compose 也能感知 Espresso 的 idling resource。 通过
IdlingRegistry.getInstance().register(…)注册的任何资源,都会被rule.waitForIdle()等待,因为测试规则会通过同一桥接机制轮询 Espresso。
模式
模式:从 runOnIdle 调用 Espresso.onView 会导致死锁
// 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())
}
模式:通过 Espresso 设置对话框焦点,通过 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)
}
模式:createComposeRule() 无法访问 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())
强制规则
- 必须在测试线程上、调用
rule.waitForIdle()之后调用Espresso.onView(...)。不得将其包装在rule.runOnIdle { }或rule.runOnUiThread { }中——Espresso 的空闲等待在 UI 线程上会抛出异常(EspressoLink.android.kt:75-83)。 - 必须在任何需要访问宿主 Activity 的 View ID 的测试中使用
createAndroidComposeRule<A>(StandardTestDispatcher())。createComposeRule()只会启动来自ui-test-manifest的基础ComponentActivity。 - 不得手动通过
IdlingRegistry.getInstance()注册 ComposeIdlingResource——框架会通过EspressoLink(名为"Compose-Espresso link"的单个桥接资源)完成此操作。引用见EspressoLink.android.kt:34-84。 - **优先:**当 Activity 承载多个
ComposeView时,通过rule.onRootWithViewInteraction(viewInteraction)将 Compose 交互限定到 Compose 子树。在非AndroidComposeTestRule实现上调用该方法会抛出IllegalStateException(源码在ComposeTestRuleExt.android.kt:38-42中使用error("This implementation of ComposeTestRule does not support onRootWithViewInteraction."))。 - **优先:**对于软键盘测试,遵循
rule.setContent { … }→rule.waitForIdle()→Espresso.onView(supportsInputMethods()).perform(click())→keyboardHelper.waitForKeyboardVisibility(visible = true)(TextFieldFocusCustomDialogTest.kt:117-128)。 - 不得将此技能用于
androidx.compose.ui.window.Dialog(Compose 对话框)——这类对话框会作为由isDialog()匹配的语义节点呈现。请参阅../../finders/composing-semantics-matchers/SKILL.md。
验证
- 规则为
createAndroidComposeRule<HostActivity>(StandardTestDispatcher()),而不是createComposeRule()。 - 每个
Espresso.onView(...)调用都位于测试方法作用域内,而不是位于runOnIdle { }/runOnUiThread { }/setContent { }内。 - 在 Compose 操作与下一次
Espresso.onView(...)调用之间(或反过来)调用rule.waitForIdle()。 - 不要手动调用
IdlingRegistry.getInstance().register(composeIdlingResource)——框架会通过EspressoLink完成此操作。 - 当一个 Activity 中存在多个
ComposeView时,通过rule.onRootWithViewInteraction(...)限定查找器的作用范围。 - 不要使用
Thread.sleep来“等待 Espresso 稳定下来”——rule.waitForIdle()已经会通过桥接轮询 Espresso。请参阅../../synchronization/synchronizing-with-idle/SKILL.md。
参考资料
- Espresso onView API:https://developer.android.com/training/testing/espresso/basics
- Compose-Espresso 互操作指南:https://developer.android.com/develop/ui/compose/testing#interop-uiautomator-espresso
- IME 互操作规范测试:
compose/foundation/foundation/src/androidDeviceTest/kotlin/androidx/compose/foundation/textfield/TextFieldFocusCustomDialogTest.kt:57-128 EspressoLink桥接实现:compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/EspressoLink.android.kt:34-84onRootWithViewInteraction定义: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 工具:
compose/foundation/foundation/src/androidDeviceTest/kotlin/androidx/compose/foundation/text/KeyboardHelper.kt