Espresso UI Interactions: Advanced Actions, Gestures, and Multi-Window Testing

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.

Read more

Start now free