关于 writing-espresso-tests
面向 Android Views UI 测试的 Espresso 技能指南,适用于编写和排查 Activity、Fragment 与 Dialog 的仪器测试。内容涵盖视图匹配、操作、断言及同步陷阱,并区分 Compose 等其他测试场景。
主要功能
- 01视图匹配、操作与断言
- 02Dialog 和弹出窗口的 Root 选择
- 03RecyclerView 与 AdapterView 交互
- 04Intent 测试与存根
- 05为异步任务注册 IdlingResource
适用场景
- 01测试 Android Views 界面交互
- 02排查视图未匹配或匹配多个的问题
- 03测试 Dialog 按钮和弹出菜单
- 04同步 Espresso 无法跟踪的异步任务
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ skydoves/ android-testing-skills --skill writing-espresso-tests安装后在 Claude Code 中输入/writing-espresso-tests确认已加载。
详细介绍
编写 Espresso 测试——Android View 的「等待空闲后再操作」模型
Espresso 是 Android View 的标准插桩式 UI 测试框架(不适用于 Compose——Compose 请使用 compose/ 技能集;Compose 与 View 互操作请参阅 ../../../compose/interop/testing-with-espresso-interop/SKILL.md)。每次 onView(...).perform(...).check(...) 调用都会在内部让主 Looper 运行至空闲状态,找到且仅找到一个匹配的 View,执行操作,然后再次等待至空闲状态后再进行检查。本技能涵盖完整的匹配器/操作/断言目录、RootMatchers.DEFAULT 陷阱,以及各版本的注意事项。
何时使用此技能
- 用户正在测试 Activity、Fragment 或 Dialog,且其 UI 使用 Android View 构建(XML 布局、AppCompat、Material View 组件)。
- 测试失败并出现
NoMatchingViewException、AmbiguousViewMatcherException或PerformException。 - 点击
AlertDialog按钮后毫无反应——RootMatchers.DEFAULT不包含对话框。 - 用户想为相机、联系人或分享流程 stub
Intent.startActivityForResult。 - 用户想为 Espresso 无法感知的长时间运行任务注册
IdlingResource。 - 用户正在调试
RecyclerView交互。 - 用户提到
BottomNavigationViewActions,并对它不在 Espresso 3.7.0 contrib 的类路径上感到意外。
何时不使用此技能
- UI 使用 Jetpack Compose——请使用
compose/技能(../../../compose/finders/finding-nodes-by-tag-text-content/SKILL.md等)。混合 Compose+View 宿主:../../../compose/interop/testing-with-espresso-interop/SKILL.md。 - 测试跨越应用边界(Settings、拨号器、系统 UI)——Espresso 会抛出
InjectEventSecurityException。请使用../../uiautomator/cross-app-tests-with-uiautomator/SKILL.md。 - 尚未设置运行器栈——请从
../../runner/running-instrumented-tests-with-androidjunit4/SKILL.md开始。 - 尚未启动 Activity——请配合使用
../../scenarios/launching-activities-with-activityscenario/SKILL.md。
先决条件
- 来自
../../runner/running-instrumented-tests-with-androidjunit4/SKILL.md的运行器栈。 - Espresso 构件(根据
docs/CORPUS.mdH.1,3.7.0 GA):
dependencies {
androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0")
androidTestImplementation("androidx.test.espresso:espresso-contrib:3.7.0") // RecyclerViewActions, PickerActions
androidTestImplementation("androidx.test.espresso:espresso-intents:3.7.0") // Intents.intended/intending
androidTestImplementation("androidx.test.espresso:espresso-accessibility:3.7.0")
androidTestImplementation("androidx.test.espresso:espresso-web:3.7.0") // WebView
implementation("androidx.test.espresso:espresso-idling-resource:3.7.0") // for production code that exposes IdlingResources
}
android.testOptions.animationsDisabled = true // hermetic
- 通过
ActivityScenario/ActivityScenarioRule/FragmentScenario启动的Activity(或 Fragment)。
工作流
- 1. 使用
onView(matcher)查找 View。 匹配器是 HamcrestMatcher<View>。优先使用基于标识的匹配器:withId(R.id.button)、withTagKey(int)、withResourceName(String)。文本/描述备用匹配器:withText、withContentDescription、withHint。来源:androidx.test.espresso.matcher.ViewMatchers(第 79-1000+ 行,R3 第 180-280 行):
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.matcher.ViewMatchers.*
import org.hamcrest.Matchers.allOf
onView(withId(R.id.submit_button))
onView(allOf(withText("Submit"), isEnabled()))
onView(withContentDescription("Open menu"))
onView(allOf(withId(R.id.subtitle), hasDescendant(withText("Hello"))))
-
2. 组合感知状态的匹配器。
ViewMatchers中常见的状态匹配器:isDisplayed、isCompletelyDisplayed、isDisplayingAtLeast(percent)、isEnabled/isNotEnabled、isFocused、isChecked/isNotChecked、isSelected、withEffectiveVisibility(Visibility.VISIBLE|INVISIBLE|GONE)、withAlpha(float)。层级匹配器:withParent、hasDescendant、withChild、isRoot、withClassName、isAssignableFrom(Class)。相对布局匹配器:hasSibling、isDescendantOfA。来源:R3 第 180-280 行。 -
3. 使用
ViewActions通过.perform(action)执行操作。 来自androidx.test.espresso.action.ViewActions:
import androidx.test.espresso.action.ViewActions.*
onView(withId(R.id.email)).perform(typeText("[email protected]"), closeSoftKeyboard())
onView(withId(R.id.submit_button)).perform(click())
onView(withId(R.id.input)).perform(replaceText("new"), pressImeActionButton())
onView(withId(R.id.list)).perform(swipeUp())
onView(withId(R.id.scroller)).perform(scrollTo()) // requires single-direction ScrollView
点击操作:click、longClick、doubleClick。文本操作:typeText、replaceText、clearText、pressKey、pressImeActionButton、closeSoftKeyboard。手势操作:swipeUp、swipeDown、swipeLeft、swipeRight、scrollTo。来源:androidx.test.espresso.action.ViewActions。
- 4. 使用
ViewAssertions通过.check(...)进行断言。 来自androidx.test.espresso.assertion.ViewAssertions:
import androidx.test.espresso.assertion.ViewAssertions.*
import androidx.test.espresso.matcher.ViewMatchers.*
onView(withId(R.id.title)).check(matches(withText("Welcome")))
onView(withId(R.id.banner)).check(matches(isDisplayed()))
onView(withId(R.id.banner)).check(doesNotExist())
onView(withId(R.id.list)).check(selectedDescendantsMatch(withId(R.id.row), isCompletelyDisplayed()))
当已解析的 View 与匹配器相符时,matches(matcher) 通过;当匹配器解析到零个 View 时,doesNotExist() 通过(如果找到了一个不可见的 View,则不会通过——这种情况请使用 matches(not(isDisplayed())))。来源:R3 第 290-311 行。
- 5. 认识到隐式空闲机制。 每个
onView(...).perform(...)和.check(...)调用内部都会:- 通过 dagger 注入的
mainThreadExecutor在主线程提交操作。 - 调用
uiController.loopMainThreadUntilIdle(),直到:主线程的MessageQueue为空,所有注册到IdlingRegistry的IdlingResource均报告isIdleNow == true,所有已注册的非主线程Looper均处于空闲状态,并且 AsyncTask 线程池处于空闲状态。 - 解析视图(在当前
Root的层级结构中匹配viewMatcher)。0 个 →NoMatchingViewException;2 个或更多 →AmbiguousViewMatcherException。 - 检查
viewAction.getConstraints().matches(view)→ 如果为 false,则抛出PerformException。 - 调用
viewAction.perform(uiController, view)(或viewAssertion.check(view, null))。 - 再次循环主线程,直到空闲后才返回。
- 通过 dagger 注入的
这是先空闲,再执行模型。能在本地运行、却在 CI 上失败的测试,通常是因为非 Looper 驱动的异步路径(自定义 ExecutorService、OkHttp 的 dispatcher 等)缺少 IdlingResource。来源:R3 第 165-178、811-820 行。
- 6. 对话框和弹出窗口使用
inRoot(...)覆盖 root。RootMatchers.DEFAULT会排除对话框、弹出窗口和 toast(它匹配“具有焦点的 activity 窗口”)。对于AlertDialog按钮:
import androidx.test.espresso.matcher.RootMatchers.*
onView(withText("Submit")).inRoot(isDialog()).perform(click())
onView(withText("Copy")).inRoot(isPlatformPopup()).perform(click()) // overflow menu, popup window
onView(withText("Saved")).inRoot(isPlatformPopup()).check(matches(isDisplayed())) // toast
根据 R3 第 311 行,RootMatchers.DEFAULT 是 allOf(hasWindowLayoutParams(), allOf(anyOf(allOf(isDialog(), withDecorView(hasWindowFocus())), isSubwindowOfCurrentActivity()), isFocusable()))——这就是通用对话框按钮点击会悄无声息地匹配失败的原因。
- 7. 对
RecyclerView使用 contrib 中的RecyclerViewActions。 直接对RecyclerView的某一行使用onView(...).perform(...)不起作用,因为该行可能位于屏幕外(没有可查找的视图):
import androidx.test.espresso.contrib.RecyclerViewActions.*
onView(withId(R.id.list))
.perform(scrollToPosition<RecyclerView.ViewHolder>(20))
onView(withId(R.id.list))
.perform(actionOnItemAtPosition<RecyclerView.ViewHolder>(3, click()))
onView(withId(R.id.list))
.perform(scrollTo<RecyclerView.ViewHolder>(hasDescendant(withText("Pikachu"))))
onView(withId(R.id.list))
.perform(actionOnItem<RecyclerView.ViewHolder>(hasDescendant(withText("Pikachu")), click()))
所有 RecyclerViewActions 都要求满足 isAssignableFrom(RecyclerView.class) AND isDisplayed() 约束(R3 第 428 行)。
- 8. 对
AdapterView(ListView、Spinner)使用onData(...),而不是onView(...)。
import androidx.test.espresso.Espresso.onData
import org.hamcrest.Matchers.allOf
onData(allOf(`is`(instanceOf(String::class.java)), `is`("Item 5")))
.inAdapterView(withId(R.id.list))
.perform(click())
onData 会滚动 adapter,使目标项目出现在屏幕上,然后再解析其视图。
- 9. 使用
Intentsstub 并验证 intents。 在@Before中初始化,在@After中释放。IntentsRule(3.5+)会处理这两步:
import androidx.test.espresso.intent.rule.IntentsRule
import androidx.test.espresso.intent.Intents.*
import androidx.test.espresso.intent.matcher.IntentMatchers.*
@get:Rule val intentsRule = IntentsRule()
@Test
fun openCameraStub() {
intending(hasAction(MediaStore.ACTION_IMAGE_CAPTURE))
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent()))
onView(withId(R.id.take_photo)).perform(click())
intended(allOf(
hasAction(MediaStore.ACTION_IMAGE_CAPTURE),
hasFlag(Intent.FLAG_GRANT_READ_URI_PERMISSION),
))
}
根据 docs/CORPUS.md H.7,IntentsTestRule 已标记为 @Deprecated——请使用 IntentsRule,或手动调用 Intents.init() / Intents.release()。
- 10. 为非 Looper 异步工作注册
IdlingResource。 Espresso 默认会监控主 looper 和AsyncTask线程池;对于 OkHttp、自定义ExecutorService或传感器管线,必须提供空闲状态:
class CountingIdlingResource(private val name: String) : IdlingResource {
private val counter = AtomicInteger(0)
private var callback: IdlingResource.ResourceCallback? = null
override fun getName() = name
override fun isIdleNow() = counter.get() == 0
override fun registerIdleTransitionCallback(cb: IdlingResource.ResourceCallback) { callback = cb }
fun increment() { counter.incrementAndGet() }
fun decrement() { if (counter.decrementAndGet() == 0) callback?.onTransitionToIdle() }
}
@Before fun setUp() = IdlingRegistry.getInstance().register(myIdler)
@After fun tearDown() = IdlingRegistry.getInstance().unregister(myIdler)
// Master policy default is 60 seconds; setIdlingResourceTimeout default is 26 seconds.
// For debugger sessions, opt out of timeouts entirely with
// IdlingPolicies.setMasterPolicyTimeoutWhenDebuggerAttached(false)
// — there is no "-1 = infinite" magic value.
IdlingPolicies.setMasterPolicyTimeout(60, TimeUnit.SECONDS)
IdlingPolicies.setIdlingResourceTimeout(45, TimeUnit.SECONDS)
在生产代码中通过 increment() / decrement() 包装网络调用或异步工作(:espresso-idling-resource 工件应放在 implementation 中,而不是 androidTestImplementation 中)。
-
11. 准确解读错误。
NoMatchingViewException— 在当前 root 中,匹配器没有匹配到任何视图。确认该视图当前已显示;如果它位于对话框中,请添加.inRoot(isDialog())。AmbiguousViewMatcherException— 匹配到 2 个或更多视图。使用allOf(...)缩小范围。PerformException— 操作的约束未满足(例如,click()要求isDisplayingAtLeast(90))。异常消息中包含约束说明。InjectEventSecurityException— 事件将跨越到其他窗口。测试已触达系统 UI。请改用 UiAutomator。
模式
模式:错误与正确 — 点击对话框按钮
// WRONG
onView(withText("Submit")).perform(click())
// WRONG because: RootMatchers.DEFAULT (line 51 of RootMatchers.java) excludes dialogs.
// The matcher does not see the dialog window. Result: NoMatchingViewException ("No views in
// hierarchy found matching: with text Submit"), even though the dialog is clearly on screen.
// RIGHT
import androidx.test.espresso.matcher.RootMatchers.isDialog
onView(withText("Submit")).inRoot(isDialog()).perform(click())
弹出菜单(isPlatformPopup())和 toast 也适用相同规则。
模式:错误与正确 — BottomNavigationView 交互(Espresso 3.7.0 contrib)
// WRONG
import androidx.test.espresso.contrib.BottomNavigationViewActions
onView(withId(R.id.bottom_nav)).perform(BottomNavigationViewActions.selectItem(R.id.tab_profile))
// WRONG because: BottomNavigationViewActions does NOT exist in espresso-contrib 3.7.0.
// The class was historically documented but never shipped in 3.7.0's contrib jar (R3 lines 91-93).
// Compile fails with "unresolved reference".
// RIGHT — click the underlying menu item view directly
import com.google.android.material.bottomnavigation.BottomNavigationView
import org.hamcrest.Matchers.allOf
onView(allOf(
withId(R.id.tab_profile),
isDescendantOfA(isAssignableFrom(BottomNavigationView::class.java)),
)).perform(click())
模式:错误与正确 — PickerActions.setDate 的月份约定
// WRONG
import androidx.test.espresso.contrib.PickerActions
onView(withClassName(equalTo(DatePicker::class.java.name)))
.perform(PickerActions.setDate(2026, 4, 15)) // intends "May 15, 2026"
// WRONG because: PickerActions.setDate uses 1-12 month convention (it subtracts 1 internally
// before calling DatePicker.updateDate which uses 0-11). Passing 4 sets April, not May.
// Source: R3 line 460, PickerActions.java line 49.
// RIGHT — May = 5
onView(withClassName(equalTo(DatePicker::class.java.name)))
.perform(PickerActions.setDate(2026, 5, 15))
模式:错误与正确 — 使用 Thread.sleep 等待动画
// WRONG
onView(withId(R.id.fab)).perform(click())
Thread.sleep(500) // wait for the FAB exit animation
onView(withId(R.id.fab)).check(doesNotExist())
// WRONG because: Thread.sleep is a smell. Espresso's idle pump already waits for animations
// driven through the main looper; if it doesn't, register an IdlingResource for the offending
// async path. See `docs/CORPUS.md` D.7 — "Thread.sleep is a smell".
// RIGHT — disable animations OR register an IdlingResource
android.testOptions.animationsDisabled = true // for property animators driven via Choreographer
// And/or, for non-Looper waits:
IdlingPolicies.setMasterPolicyTimeout(45, TimeUnit.SECONDS)
IdlingRegistry.getInstance().register(myAnimationIdler)
模式:点击 RecyclerView 中滚出屏幕的行之前,先滚动到该行
import androidx.test.espresso.contrib.RecyclerViewActions.*
import androidx.test.espresso.matcher.ViewMatchers.hasDescendant
onView(withId(R.id.list))
.perform(scrollTo<RecyclerView.ViewHolder>(hasDescendant(withText("Snorlax"))))
onView(withId(R.id.list))
.perform(actionOnItem<RecyclerView.ViewHolder>(hasDescendant(withText("Snorlax")), click()))
scrollTo 和 actionOnItem 使用 HOLDER 匹配器(匹配底层 RecyclerView.ViewHolder 的 itemView),而不是针对屏幕使用 Matcher<View>。
强制规则
- 必须为任何针对
AlertDialog内容视图的匹配器添加.inRoot(isDialog()),为弹出菜单和 Toast 添加.inRoot(isPlatformPopup())。RootMatchers.DEFAULT会排除它们。 - 必须优先使用
withId/withTagKey/withResourceName,而不是withText。文本匹配器容易受国际化影响,也会随文案修改而频繁变动。 - 不得依赖
Thread.sleep等待动画或异步工作。应使用IdlingResource+IdlingPolicies.setMasterPolicyTimeout,并设置testOptions.animationsDisabled = true。 - 不得导入
androidx.test.espresso.contrib.BottomNavigationViewActions——该类不在espresso-contrib:3.7.0中。请使用isDescendantOfA(isAssignableFrom(BottomNavigationView::class.java))这一变通方案。 - 必须为
PickerActions.setDate(year, month, day)的 month 参数传入 1-12(而不是 0-11)。该操作会在调用DatePicker.updateDate前在内部减去 1。 - 必须调用
Intents.init()/Intents.release()(或使用IntentsRule)——intended/intending只有在这两次调用之间才能工作。 - 必须将
:espresso-idling-resource:3.7.0添加到implementation(生产代码),而不是androidTestImplementation。生产代码负责增加 / 减少该资源的计数。 - 不得使用
IntentsTestRule——它已标记为@Deprecated。请使用IntentsRule(3.5+)或手动调用init/release。 - **建议:**通过
testOptions.animationsDisabled = true全局禁用动画;不要手动切换Settings.Global标志。 - **建议:**仔细阅读每条
NoMatchingViewException消息——其中包含视图层级转储。你要找的视图可能存在,但位于不同的Root中。
验证
-
grep -r "Thread.sleep" src/androidTest/不返回任何结果(或者只返回等待目标为 RenderThread 的截图测试文件)。 -
grep -r "BottomNavigationViewActions" src/androidTest/不返回任何结果。 -
grep -r "IntentsTestRule" src/androidTest/不返回任何结果——请使用IntentsRule或手动调用 init/release。 - 每个
inRoot(isDialog())都对应生产代码中的AlertDialog类 API;否则测试会针对错误的根视图通过。 -
./gradlew :<module>:connectedDebugAndroidTest运行时不会出现上述列表中的NoMatchingViewException或PerformException。 - 在模块的
android块中设置了testOptions.animationsDisabled = true。 - 所有已注册的
IdlingResource都会在@After中调用IdlingRegistry.getInstance().unregister(...)(或使用 JUnit 规则)。
参考资料
- Android Developers — Espresso 概览:https://developer.android.com/training/testing/espresso
- Android Developers — Espresso 速查表(匹配器、操作、断言):https://developer.android.com/training/testing/espresso/cheat-sheet
- Android Developers — IdlingResource 指南:https://developer.android.com/training/testing/espresso/idling-resource
- AndroidX Test(Espresso)发行说明:https://developer.android.com/jetpack/androidx/releases/test
androidx/test/espresso/matcher/ViewMatchers.java(第 79-1000+ 行)— 完整的匹配器目录。身份(withId、withTagKey、withResourceName);文本(withText、withSubstring、withHint);状态(isDisplayed、isEnabled、isChecked、withEffectiveVisibility);层级(withParent、hasDescendant、withChild、isRoot、isDescendantOfA)。androidx/test/espresso/matcher/RootMatchers.java第 51 行 —DEFAULT = allOf(hasWindowLayoutParams(), allOf(anyOf(allOf(isDialog(), withDecorView(hasWindowFocus())), isSubwindowOfCurrentActivity()), isFocusable()))。这是个陷阱。androidx/test/espresso/action/ViewActions.java—click、longClick、doubleClick、typeText、replaceText、clearText、pressKey、swipeUp/Down/Left/Right、scrollTo、pressImeActionButton、closeSoftKeyboard。androidx/test/espresso/assertion/ViewAssertions.java第 51-118 行 —matches、doesNotExist、selectedDescendantsMatch。androidx/test/espresso/contrib/RecyclerViewActions.java第 200-249 行 —scrollToPosition、scrollTo、actionOnItemAtPosition、actionOnItem。约束:isAssignableFrom(RecyclerView) AND isDisplayed。androidx/test/espresso/contrib/PickerActions.java第 36-100 行 —setDate(year, monthOfYear, dayOfMonth),其中 monthOfYear 为 1-12(第 49 行 KDoc)。setTime(hours, minutes)。androidx/test/espresso/IdlingPolicies.java第 96-112 行 —setMasterPolicyTimeout、setIdlingResourceTimeout、setMasterPolicyTimeoutWhenDebuggerAttached。androidx/test/espresso/intent/Intents.java—init、release、intended、intending。androidx.test.espresso.intent.rule.IntentsRule是现代 JUnit4 封装。tasks/research/R3-espresso.md— Espresso 3.7.0 深入研究全文。第 91-93 行(缺少 BottomNavigationViewActions)、第 311 行(RootMatchers.DEFAULT)、第 456-460 行(PickerActions 的月份约定)、第 811-820 行(空闲后再执行操作)。docs/CORPUS.mdH.5 节 — Espresso 基础知识和RootMatchers.DEFAULT的排除项。