mcpField
W
writing-espresso-tests

writing-espresso-tests

github.com/skydoves测试与质量33963 次安装22 周前更新
代码仓库

关于 writing-espresso-tests

面向 Android Views UI 测试的 Espresso 技能指南,适用于编写和排查 Activity、Fragment 与 Dialog 的仪器测试。内容涵盖视图匹配、操作、断言及同步陷阱,并区分 Compose 等其他测试场景。

主要功能

  1. 01视图匹配、操作与断言
  2. 02Dialog 和弹出窗口的 Root 选择
  3. 03RecyclerView 与 AdapterView 交互
  4. 04Intent 测试与存根
  5. 05为异步任务注册 IdlingResource

适用场景

  1. 01测试 Android Views 界面交互
  2. 02排查视图未匹配或匹配多个的问题
  3. 03测试 Dialog 按钮和弹出菜单
  4. 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.md H.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。 匹配器是 Hamcrest Matcher<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(...) 调用内部都会:
    1. 通过 dagger 注入的 mainThreadExecutor 在主线程提交操作。
    2. 调用 uiController.loopMainThreadUntilIdle(),直到:主线程的 MessageQueue 为空,所有注册到 IdlingRegistry 的 IdlingResource 均报告 isIdleNow == true,所有已注册的非主线程 Looper 均处于空闲状态,并且 AsyncTask 线程池处于空闲状态。
    3. 解析视图(在当前 Root 的层级结构中匹配 viewMatcher)。0 个 → NoMatchingViewException;2 个或更多 → AmbiguousViewMatcherException。
    4. 检查 viewAction.getConstraints().matches(view) → 如果为 false,则抛出 PerformException。
    5. 调用 viewAction.perform(uiController, view)(或 viewAssertion.check(view, null))。
    6. 再次循环主线程,直到空闲后才返回。

这是先空闲,再执行模型。能在本地运行、却在 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. 使用 Intents stub 并验证 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.md H.5 节 — Espresso 基础知识和 RootMatchers.DEFAULT 的排除项。

评论

支持 Markdown