技能创建器
skill-creator
创建、改进和评估自定义技能,把专业流程转化为可重复使用的 AI 能力。
writing-espresso-tests【数据与研究】Use this skill to write Espresso 3.7.0 tests against Android Views — `onView(matcher).perform(action).check(matches(...))`, `onData(...)` for `AdapterView`, `RecyclerViewActions`, `Intents.intended/intending`, and `IdlingResource` registration. Covers the full `ViewMatchers` / `ViewActions` / `ViewAssertions` catalog, the `RootMatchers.DEFAULT` exclu…
独立 GitHub 快照 · 自动分类 · 以原作者文档为准
name: writing-espresso-tests
description: Use this skill to write Espresso 3.7.0 tests against Android Views — onView(matcher).perform(action).check(matches(...)), onData(...) for AdapterView, RecyclerViewActions, Intents.intended/intending, and IdlingResource registration. Covers the full ViewMatchers / ViewActions / ViewAssertions catalog, the RootMatchers.DEFAULT exclusion of dialogs and popups (the inRoot(isDialog()) requirement), the BottomNavigationViewActions doesn't-exist gotcha in 3.7.0 contrib, and the PickerActions.setDate(year, month, day) 1-12 month convention. Use when the user reports NoMatchingViewException, AmbiguousViewMatcherException, PerformException, asks "how do I click in a dialog", "Espresso typeText doesn't fire", "intent stub for camera", or "Espresso vs UiAutomator".
license: Apache-2.0. See LICENSE for complete terms.
metadata:
author: Jaewoong Eum (skydoves)
keywords:
Espresso is the canonical instrumentation-time UI test framework for Android Views (not Compose — for Compose, use the compose/ skill set; for cross-Compose-View interop see ../../../compose/interop/testing-with-espresso-interop/SKILL.md). Each onView(...).perform(...).check(...) call internally pumps the main looper to idle, finds exactly one matching view, runs the action, and pumps to idle again before checking. This skill encodes the full matcher/action/assertion catalog, the RootMatchers.DEFAULT trap, and the per-version gotchas.
NoMatchingViewException, AmbiguousViewMatcherException, or PerformException.AlertDialog button silently does nothing — RootMatchers.DEFAULT excludes dialogs.Intent.startActivityForResult for camera, contacts, or share flows.IdlingResource for a long-running task that Espresso can't see.RecyclerView interaction.BottomNavigationViewActions and is surprised it isn't on the classpath in Espresso 3.7.0 contrib.compose/ skills (../../../compose/finders/finding-nodes-by-tag-text-content/SKILL.md, etc.). Mixed Compose+View hosts: ../../../compose/interop/testing-with-espresso-interop/SKILL.md.InjectEventSecurityException from Espresso. Use ../../uiautomator/cross-app-tests-with-uiautomator/SKILL.md.../../runner/running-instrumented-tests-with-androidjunit4/SKILL.md.../../scenarios/launching-activities-with-activityscenario/SKILL.md.../../runner/running-instrumented-tests-with-androidjunit4/SKILL.md.docs/CORPUS.md H.1):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
Activity (or Fragment) launched via ActivityScenario / ActivityScenarioRule / FragmentScenario.onView(matcher). The matcher is a Hamcrest Matcher<View>. Identity-first matchers (most preferred): withId(R.id.button), withTagKey(int), withResourceName(String). Text/desc fallback: withText, withContentDescription, withHint. Source: androidx.test.espresso.matcher.ViewMatchers (lines 79-1000+, R3 lines 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. Compose state-aware matchers. Common state matchers from ViewMatchers: isDisplayed, isCompletelyDisplayed, isDisplayingAtLeast(percent), isEnabled/isNotEnabled, isFocused, isChecked/isNotChecked, isSelected, withEffectiveVisibility(Visibility.VISIBLE|INVISIBLE|GONE), withAlpha(float). Hierarchy matchers: withParent, hasDescendant, withChild, isRoot, withClassName, isAssignableFrom(Class). Layout-relative: hasSibling, isDescendantOfA. Source: R3 lines 180-280.
3. Act with ViewActions via .perform(action). From androidx.test.espresso.action.ViewActions:
import androidx.test.espresso.action.ViewActions.*
onView(withId(R.id.email)).perform(typeText("test@example.com"), 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 family: click, longClick, doubleClick. Text: typeText, replaceText, clearText, pressKey, pressImeActionButton, closeSoftKeyboard. Gesture: swipeUp, swipeDown, swipeLeft, swipeRight, scrollTo. Source: androidx.test.espresso.action.ViewActions.
ViewAssertions via .check(...). From 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()))
matches(matcher) passes when the resolved view matches; doesNotExist() passes when the matcher resolved zero views (NOT when one was found and is invisible — use matches(not(isDisplayed())) for that). Source: R3 lines 290-311.
onView(...).perform(...) and .check(...) call internally:
mainThreadExecutor.uiController.loopMainThreadUntilIdle() until: the main MessageQueue is empty, all IdlingResources registered with IdlingRegistry report isIdleNow == true, all registered non-main Loopers are idle, and the AsyncTask thread pool is idle.viewMatcher against the current Root's hierarchy). 0 → NoMatchingViewException; 2+ → AmbiguousViewMatcherException.viewAction.getConstraints().matches(view) → throws PerformException if false.viewAction.perform(uiController, view) (or viewAssertion.check(view, null)).This is the idle-then-act model. Tests that work locally but fail on CI are usually missing an IdlingResource for a non-Looper-driven async path (custom ExecutorService, OkHttp's dispatcher, etc.). Source: R3 lines 165-178, 811-820.
inRoot(...). RootMatchers.DEFAULT excludes dialogs, popups, and toasts (it matches "the focused activity window"). For an AlertDialog button: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
RootMatchers.DEFAULT is allOf(hasWindowLayoutParams(), allOf(anyOf(allOf(isDialog(), withDecorView(hasWindowFocus())), isSubwindowOfCurrentActivity()), isFocusable())) per R3 line 311 — that's why a generic dialog button click silently misses.
RecyclerView, use RecyclerViewActions from contrib. onView(...).perform(...) against a RecyclerView's row directly does NOT work because the row may be off-screen (no view to find):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()))
All RecyclerViewActions require isAssignableFrom(RecyclerView.class) AND isDisplayed() constraint (R3 line 428).
AdapterView (ListView, Spinner), use onData(...) not 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 scrolls the adapter to bring the item on-screen before resolving its view.
Intents. Init in @Before, release in @After. The IntentsRule (3.5+) handles both: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),
))
}
IntentsTestRule is @Deprecated per docs/CORPUS.md H.7 — use IntentsRule or call Intents.init() / Intents.release() manually.
IdlingResource for non-Looper async work. Espresso watches the main looper and the AsyncTask pool by default; for OkHttp, custom ExecutorService, or sensor pipelines you must surface idleness: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)
Wrap network calls / async work in increment() / decrement() from production code (the :espresso-idling-resource artifact is on implementation, not androidTestImplementation).
11. Read errors precisely.
NoMatchingViewException — the matcher resolved zero views in the current root. Confirm the view is currently displayed; if it is in a dialog, add .inRoot(isDialog()).AmbiguousViewMatcherException — 2+ views matched. Tighten with allOf(...).PerformException — the action's constraint failed (e.g. click() requires isDisplayingAtLeast(90)). The exception message includes the constraint description.InjectEventSecurityException — an event would cross to a foreign window. The test reached system UI. Use UiAutomator instead.// 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())
Same applies to popup menus (isPlatformPopup()) and toasts.
BottomNavigationView interaction (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 month convention// 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 to wait for an animation// 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 to an off-screen row before clicking itimport 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 and actionOnItem use a HOLDER matcher (matches the underlying RecyclerView.ViewHolder's itemView), not a Matcher<View> against the screen.
.inRoot(isDialog()) for any matcher targeting an AlertDialog content view, .inRoot(isPlatformPopup()) for popup menus and toasts. RootMatchers.DEFAULT excludes them.withId / withTagKey / withResourceName over withText. Text matchers are i18n-fragile and churn with copy edits.Thread.sleep to wait for animations or async work. Use IdlingResource + IdlingPolicies.setMasterPolicyTimeout, and testOptions.animationsDisabled = true.androidx.test.espresso.contrib.BottomNavigationViewActions — the class is not in espresso-contrib:3.7.0. Use the isDescendantOfA(isAssignableFrom(BottomNavigationView::class.java)) workaround.PickerActions.setDate(year, month, day). The action subtracts 1 internally before calling DatePicker.updateDate.Intents.init() / Intents.release() (or use IntentsRule) — intended / intending only work between those calls.:espresso-idling-resource:3.7.0 on implementation (production), not androidTestImplementation. The production code is what increments / decrements the resource.IntentsTestRule — it is @Deprecated. Use IntentsRule (3.5+) or manual init/release.testOptions.animationsDisabled = true; do not toggle Settings.Global flags by hand.NoMatchingViewException message — it includes a hierarchy dump. The view you wanted may be present but in a different Root.grep -r "Thread.sleep" src/androidTest/ returns nothing (or only screenshot-test files where the RenderThread is the wait target).grep -r "BottomNavigationViewActions" src/androidTest/ returns nothing.grep -r "IntentsTestRule" src/androidTest/ returns nothing — use IntentsRule or manual init/release.inRoot(isDialog()) matches an AlertDialog-class API in production; otherwise the test passes against the wrong root../gradlew :<module>:connectedDebugAndroidTest runs without NoMatchingViewException or PerformException from the catalog above.testOptions.animationsDisabled = true is set in the module's android block.IdlingResources call IdlingRegistry.getInstance().unregister(...) in @After (or use a JUnit rule).androidx/test/espresso/matcher/ViewMatchers.java (lines 79-1000+) — full matcher catalog. Identity (withId, withTagKey, withResourceName); text (withText, withSubstring, withHint); state (isDisplayed, isEnabled, isChecked, withEffectiveVisibility); hierarchy (withParent, hasDescendant, withChild, isRoot, isDescendantOfA).androidx/test/espresso/matcher/RootMatchers.java line 51 — DEFAULT = allOf(hasWindowLayoutParams(), allOf(anyOf(allOf(isDialog(), withDecorView(hasWindowFocus())), isSubwindowOfCurrentActivity()), isFocusable())). The trap.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 lines 51-118 — matches, doesNotExist, selectedDescendantsMatch.androidx/test/espresso/contrib/RecyclerViewActions.java lines 200-249 — scrollToPosition, scrollTo, actionOnItemAtPosition, actionOnItem. Constraint: isAssignableFrom(RecyclerView) AND isDisplayed.androidx/test/espresso/contrib/PickerActions.java lines 36-100 — setDate(year, monthOfYear, dayOfMonth) with monthOfYear 1-12 (line 49 KDoc). setTime(hours, minutes).androidx/test/espresso/IdlingPolicies.java lines 96-112 — setMasterPolicyTimeout, setIdlingResourceTimeout, setMasterPolicyTimeoutWhenDebuggerAttached.androidx/test/espresso/intent/Intents.java — init, release, intended, intending. androidx.test.espresso.intent.rule.IntentsRule is the modern JUnit4 wrapper.tasks/research/R3-espresso.md — full Espresso 3.7.0 deep-dive. Lines 91-93 (BottomNavigationViewActions absent), 311 (RootMatchers.DEFAULT), 456-460 (PickerActions month convention), 811-820 (idle-then-act).docs/CORPUS.md Section H.5 — Espresso essentials and RootMatchers.DEFAULT exclusions.