Writing Espresso Tests — The Idle-Then-Act Model for Android Views
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.
When to use this skill
- The user is testing an Activity, Fragment, or Dialog whose UI is built with Android Views (XML layouts, AppCompat, Material View components).
- A test fails with
NoMatchingViewException,AmbiguousViewMatcherException, orPerformException. - A click on an
AlertDialogbutton silently does nothing —RootMatchers.DEFAULTexcludes dialogs. - The user wants to stub
Intent.startActivityForResultfor camera, contacts, or share flows. - The user wants to register an
IdlingResourcefor a long-running task that Espresso can't see. - The user is debugging a
RecyclerViewinteraction. - The user mentions
BottomNavigationViewActionsand is surprised it isn't on the classpath in Espresso 3.7.0 contrib.
When NOT to use this skill
- The UI is Jetpack Compose — use
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. - The test crosses app boundaries (Settings, dialer, system UI) —
InjectEventSecurityExceptionfrom Espresso. Use../../uiautomator/cross-app-tests-with-uiautomator/SKILL.md. - The runner stack is not yet set up — start with
../../runner/running-instrumented-tests-with-androidjunit4/SKILL.md. - The Activity has not been launched — pair with
../../scenarios/launching-activities-with-activityscenario/SKILL.md.
Prerequisites
- Runner stack from
../../runner/running-instrumented-tests-with-androidjunit4/SKILL.md. - Espresso artifacts (3.7.0 GA per
docs/CORPUS.mdH.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
- An
Activity(or Fragment) launched viaActivityScenario/ActivityScenarioRule/FragmentScenario.
Workflow
- 1. Find views with
onView(matcher). The matcher is a HamcrestMatcher<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
ViewActionsvia.perform(action). Fromandroidx.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.
- 4. Assert with
ViewAssertionsvia.check(...). Fromandroidx.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.
- 5. Recognize the implicit idle. Every
onView(...).perform(...)and.check(...)call internally:- Submits the action on the main thread via the dagger-injected
mainThreadExecutor. - Calls
uiController.loopMainThreadUntilIdle()until: the mainMessageQueueis empty, allIdlingResources registered withIdlingRegistryreportisIdleNow == true, all registered non-mainLoopers are idle, and the AsyncTask thread pool is idle. - Resolves the view (
viewMatcheragainst the currentRoot's hierarchy). 0 →NoMatchingViewException; 2+ →AmbiguousViewMatcherException. - Checks
viewAction.getConstraints().matches(view)→ throwsPerformExceptionif false. - Calls
viewAction.perform(uiController, view)(orviewAssertion.check(view, null)). - Loops the main thread to idle again before returning.
- Submits the action on the main thread via the dagger-injected
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.
- 6. For dialogs and popups, override the root with
inRoot(...).RootMatchers.DEFAULTexcludes dialogs, popups, and toasts (it matches "the focused activity window"). For anAlertDialogbutton:
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.
- 7. For
RecyclerView, useRecyclerViewActionsfrom contrib.onView(...).perform(...)against aRecyclerView'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).
- 8. For
AdapterView(ListView, Spinner), useonData(...)notonView(...).
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.
- 9. Stub and verify intents with
Intents. Init in@Before, release in@After. TheIntentsRule(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.
- 10. Register
IdlingResourcefor non-Looper async work. Espresso watches the main looper and theAsyncTaskpool by default; for OkHttp, customExecutorService, 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 withallOf(...).PerformException— the action's constraint failed (e.g.click()requiresisDisplayingAtLeast(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.
Patterns
Pattern: WRONG vs RIGHT — clicking a dialog button
// 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.
Pattern: WRONG vs RIGHT — 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())
Pattern: WRONG vs RIGHT — 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))
Pattern: WRONG vs RIGHT — 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)
Pattern: scrolling a RecyclerView to an off-screen row before clicking it
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 and actionOnItem use a HOLDER matcher (matches the underlying RecyclerView.ViewHolder's itemView), not a Matcher<View> against the screen.
Mandatory rules
- MUST add
.inRoot(isDialog())for any matcher targeting anAlertDialogcontent view,.inRoot(isPlatformPopup())for popup menus and toasts.RootMatchers.DEFAULTexcludes them. - MUST prefer
withId/withTagKey/withResourceNameoverwithText. Text matchers are i18n-fragile and churn with copy edits. - MUST NOT rely on
Thread.sleepto wait for animations or async work. UseIdlingResource+IdlingPolicies.setMasterPolicyTimeout, andtestOptions.animationsDisabled = true. - MUST NOT import
androidx.test.espresso.contrib.BottomNavigationViewActions— the class is not inespresso-contrib:3.7.0. Use theisDescendantOfA(isAssignableFrom(BottomNavigationView::class.java))workaround. - MUST pass 1-12 (not 0-11) for the month parameter of
PickerActions.setDate(year, month, day). The action subtracts 1 internally before callingDatePicker.updateDate. - MUST call
Intents.init()/Intents.release()(or useIntentsRule) —intended/intendingonly work between those calls. - MUST put
:espresso-idling-resource:3.7.0onimplementation(production), notandroidTestImplementation. The production code is what increments / decrements the resource. - MUST NOT use
IntentsTestRule— it is@Deprecated. UseIntentsRule(3.5+) or manualinit/release. - PREFERRED: disable animations globally via
testOptions.animationsDisabled = true; do not toggleSettings.Globalflags by hand. - PREFERRED: read every
NoMatchingViewExceptionmessage — it includes a hierarchy dump. The view you wanted may be present but in a differentRoot.
Verification
-
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 — useIntentsRuleor manual init/release. - Every
inRoot(isDialog())matches anAlertDialog-class API in production; otherwise the test passes against the wrong root. -
./gradlew :<module>:connectedDebugAndroidTestruns withoutNoMatchingViewExceptionorPerformExceptionfrom the catalog above. -
testOptions.animationsDisabled = trueis set in the module'sandroidblock. - All registered
IdlingResources callIdlingRegistry.getInstance().unregister(...)in@After(or use a JUnit rule).
References
- Android Developers — Espresso overview: https://developer.android.com/training/testing/espresso
- Android Developers — Espresso cheat sheet (matchers, actions, assertions): https://developer.android.com/training/testing/espresso/cheat-sheet
- Android Developers — IdlingResource guide: https://developer.android.com/training/testing/espresso/idling-resource
- AndroidX Test (Espresso) release notes: https://developer.android.com/jetpack/androidx/releases/test
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.javaline 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.javalines 51-118 —matches,doesNotExist,selectedDescendantsMatch.androidx/test/espresso/contrib/RecyclerViewActions.javalines 200-249 —scrollToPosition,scrollTo,actionOnItemAtPosition,actionOnItem. Constraint:isAssignableFrom(RecyclerView) AND isDisplayed.androidx/test/espresso/contrib/PickerActions.javalines 36-100 —setDate(year, monthOfYear, dayOfMonth)with monthOfYear 1-12 (line 49 KDoc).setTime(hours, minutes).androidx/test/espresso/IdlingPolicies.javalines 96-112 —setMasterPolicyTimeout,setIdlingResourceTimeout,setMasterPolicyTimeoutWhenDebuggerAttached.androidx/test/espresso/intent/Intents.java—init,release,intended,intending.androidx.test.espresso.intent.rule.IntentsRuleis 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.mdSection H.5 — Espresso essentials andRootMatchers.DEFAULTexclusions.