Espresso UI Interactions: Advanced Actions, Gestures, and Multi-Window Testing
Beyond basic clicks and text input, Espresso supports complex UI interactions through custom ViewActions, the Intents API, WebView testing, and accessibility actions. This guide covers the advanced interaction patterns you'll need for real-world Android test suites.
Custom ViewActions
When built-in actions aren't enough, implement ViewAction:
import androidx.test.espresso.UiController
import androidx.test.espresso.ViewAction
import org.hamcrest.Matcher
// Click a child view within a RecyclerView item
fun clickChildViewWithId(id: Int): ViewAction {
return object : ViewAction {
override fun getConstraints(): Matcher<View> = isDisplayed()
override fun getDescription() = "Click child view with id $id"
override fun perform(uiController: UiController, view: View) {
val child = view.findViewById<View>(id)
child.performClick()
}
}
}
// Usage
onView(withId(R.id.recyclerView))
.perform(RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(
2, clickChildViewWithId(R.id.deleteButton)
))Setting a Specific Checkbox State
fun setChecked(checked: Boolean): ViewAction {
return object : ViewAction {
override fun getConstraints(): Matcher<View> =
allOf(isAssignableFrom(CompoundButton::class.java), isDisplayed())
override fun getDescription() = "Set checkbox to ${if (checked) "checked" else "unchecked"}"
override fun perform(uiController: UiController, view: View) {
val checkbox = view as CompoundButton
if (checkbox.isChecked != checked) {
checkbox.performClick()
}
}
}
}
// Usage — set to checked regardless of current state
onView(withId(R.id.termsCheckbox)).perform(setChecked(true))Typing Without Clicking First
When an EditText gets focus programmatically and the soft keyboard appears:
fun typeTextIntoFocusedView(text: String): ViewAction {
return object : ViewAction {
override fun getConstraints(): Matcher<View> = isFocused()
override fun getDescription() = "Type '$text' into focused view"
override fun perform(uiController: UiController, view: View) {
uiController.injectString(text)
}
}
}Gestures
Swipe Actions
import androidx.test.espresso.action.GeneralSwipeAction
import androidx.test.espresso.action.CoordinatesProvider
import androidx.test.espresso.action.Swipe
import androidx.test.espresso.action.Press
// Swipe left on a specific view
onView(withId(R.id.swipeableCard)).perform(swipeLeft())
onView(withId(R.id.swipeableCard)).perform(swipeRight())
onView(withId(R.id.scrollContent)).perform(swipeUp())
onView(withId(R.id.scrollContent)).perform(swipeDown())
// Pull to refresh
onView(withId(R.id.swipeRefreshLayout)).perform(swipeDown())
// Custom swipe with coordinates
onView(withId(R.id.mapView)).perform(
GeneralSwipeAction(
Swipe.FAST,
CoordinatesProvider { view ->
val location = IntArray(2)
view.getLocationOnScreen(location)
floatArrayOf(location[0] + view.width * 0.5f, location[1] + view.height * 0.3f)
},
CoordinatesProvider { view ->
val location = IntArray(2)
view.getLocationOnScreen(location)
floatArrayOf(location[0] + view.width * 0.5f, location[1] + view.height * 0.7f)
},
Press.FINGER
)
)Drag and Drop
import androidx.test.espresso.action.ViewActions.actionWithAssertions
import androidx.test.espresso.action.DragAndDropAction
// Drag from one view to another
onView(withId(R.id.sourceItem))
.perform(DragAndDropAction(
startLocation = GeneralLocation.CENTER,
endLocation = GeneralLocation.CENTER,
destinationMatcher = withId(R.id.dropTarget)
))Long Press and Context Menus
// Long press to open context menu
onView(withId(R.id.listItem)).perform(longClick())
// Then interact with context menu
onView(withText("Delete")).perform(click())
// Or with PopupMenu
onView(withId(R.id.overflowMenu)).perform(click())
onView(withText(R.string.menu_share)).perform(click())Intent Testing with Espresso Intents
Test that your app sends the correct intents and handle incoming intents:
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.5.1'Verifying Sent Intents
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers.*
import androidx.test.espresso.intent.rule.IntentsRule
@RunWith(AndroidJUnit4::class)
class ShareActivityTest {
@get:Rule
val activityRule = ActivityScenarioRule(MainActivity::class.java)
@get:Rule
val intentsRule = IntentsRule() // Initializes and releases Intents
@Test
fun clickShare_launchesShareIntent() {
onView(withId(R.id.shareButton)).perform(click())
intended(allOf(
hasAction(Intent.ACTION_SEND),
hasType("text/plain"),
hasExtra(Intent.EXTRA_TEXT, "Check out this content")
))
}
@Test
fun clickEmail_launchesEmailApp() {
onView(withId(R.id.emailButton)).perform(click())
intended(allOf(
hasAction(Intent.ACTION_SENDTO),
hasData(Uri.parse("mailto:support@example.com"))
))
}
@Test
fun clickCall_launchesDialer() {
onView(withId(R.id.callButton)).perform(click())
intended(allOf(
hasAction(Intent.ACTION_DIAL),
hasData(Uri.parse("tel:+1234567890"))
))
}
}Stubbing Intents (Preventing External App Launch)
In CI environments, you can't launch Camera, Gallery, or Contacts apps. Stub them:
@Test
fun selectPhoto_fromGallery_displaysPreview() {
// Stub the gallery intent to return a specific URI
val imageUri = Uri.parse("content://media/external/images/media/1")
val resultData = Intent().apply {
data = imageUri
}
intending(hasAction(Intent.ACTION_PICK)).respondWith(
Instrumentation.ActivityResult(Activity.RESULT_OK, resultData)
)
// Trigger the gallery open
onView(withId(R.id.pickImageButton)).perform(click())
// Verify the image was set
onView(withId(R.id.imagePreview)).check(matches(isDisplayed()))
}
@Test
fun cameraCapture_dismissed_noPreviewShown() {
// Stub camera to return RESULT_CANCELED
intending(hasAction(MediaStore.ACTION_IMAGE_CAPTURE)).respondWith(
Instrumentation.ActivityResult(Activity.RESULT_CANCELED, null)
)
onView(withId(R.id.capturePhotoButton)).perform(click())
onView(withId(R.id.imagePreview)).check(matches(not(isDisplayed())))
}WebView Testing
Test WebView content with espresso-web:
androidTestImplementation 'androidx.test.espresso:espresso-web:3.5.1'import androidx.test.espresso.web.sugar.Web.onWebView
import androidx.test.espresso.web.webdriver.DriverAtoms.*
import androidx.test.espresso.web.assertion.WebViewAssertions.*
@Test
fun webView_loadsCorrectContent() {
// Enable JavaScript evaluation in the WebView
onView(withId(R.id.webView)).perform(enableJavascript())
// Check the page title via JavaScript
onWebView()
.withElement(findElement(Locator.ID, "main-title"))
.check(webMatches(getText(), containsString("Welcome")))
}
@Test
fun webView_formSubmission_works() {
onView(withId(R.id.webView)).perform(enableJavascript())
onWebView()
.withElement(findElement(Locator.ID, "username"))
.perform(webClick())
.perform(webKeys("testuser"))
onWebView()
.withElement(findElement(Locator.CSS_SELECTOR, "input[type='submit']"))
.perform(webClick())
onWebView()
.withElement(findElement(Locator.ID, "success-message"))
.check(webMatches(getText(), containsString("Logged in")))
}Testing Multiple Windows and Dialogs
System Dialogs (AlertDialog)
@Test
fun deleteAccount_showsConfirmationDialog() {
onView(withId(R.id.deleteAccountButton)).perform(click())
// Verify dialog title
onView(withText("Delete Account")).check(matches(isDisplayed()))
// Verify dialog message
onView(withText(containsString("This action cannot be undone")))
.check(matches(isDisplayed()))
// Cancel the dialog
onView(withId(android.R.id.button2)).perform(click()) // Negative button
// Verify we're still on the same screen
onView(withId(R.id.deleteAccountButton)).check(matches(isDisplayed()))
}Custom Dialogs and Bottom Sheets
@Test
fun filter_bottomSheet_applies_correctly() {
// Open bottom sheet
onView(withId(R.id.filterButton)).perform(click())
// Bottom sheet is visible
onView(withId(R.id.filterBottomSheet)).check(matches(isDisplayed()))
// Select a filter option
onView(allOf(withId(R.id.categoryChip), withText("Electronics")))
.perform(click())
// Apply filters
onView(withId(R.id.applyFiltersButton)).perform(click())
// Bottom sheet dismissed, results filtered
onView(withId(R.id.filterBottomSheet)).check(doesNotExist())
onView(withId(R.id.activeFiltersLabel))
.check(matches(withText("Electronics")))
}Overflow Menus and Navigation Drawer
@Test
fun navigationDrawer_opensAndNavigates() {
// Open navigation drawer
onView(withContentDescription("Navigate up")).perform(click())
// Or for drawer opened via gesture:
onView(withId(R.id.drawerLayout))
.perform(DrawerActions.open())
// Click menu item
onView(withId(R.id.nav_profile)).perform(click())
// Verify navigation
onView(withId(R.id.profileHeader)).check(matches(isDisplayed()))
}Testing Back Navigation
@Test
fun backNavigation_returnsToMainScreen() {
// Navigate to details
onView(withId(R.id.itemCard)).perform(click())
onView(withId(R.id.detailsTitle)).check(matches(isDisplayed()))
// Press back
Espresso.pressBack()
// Verify we're back on the main screen
onView(withId(R.id.mainTitle)).check(matches(isDisplayed()))
}
@Test
fun backPressOnMain_exitsApp() {
// Press back on the main activity
// This would normally close the app — handle with:
activityRule.scenario.onActivity { activity ->
activity.onBackPressed()
}
// Or verify the activity is finishing
assertTrue(activityRule.scenario.state.isAtLeast(Lifecycle.State.DESTROYED))
}Accessibility Actions
Espresso supports accessibility-based interactions:
// Perform accessibility click (triggers performAccessibilityAction)
onView(withId(R.id.button))
.perform(ViewActions.actionWithAssertions(
object : ViewAction {
override fun getConstraints() = isDisplayed()
override fun getDescription() = "Accessibility click"
override fun perform(uiController: UiController, view: View) {
ViewCompat.performAccessibilityAction(view, AccessibilityNodeInfoCompat.ACTION_CLICK, null)
}
}
))
// Test with TalkBack-style interactions
onView(withContentDescription("Close dialog"))
.check(matches(isDisplayed()))
.perform(click())Summary
Espresso's interaction model scales from basic click-and-assert to complex scenarios involving custom gestures, intent stubs, WebView content, and multi-window flows. The key to maintainable interaction tests is building a set of reusable custom actions for your app's patterns (like clickChildViewWithId for RecyclerView) and using intent stubbing consistently for external app interactions.
The next level of Espresso testing involves custom matchers for your app's domain objects and IdlingResources for asynchronous operations — both covered in separate articles in this series.