← Back to DevBytes

Espresso: Complete Testing Guide for Developers

What is Espresso?

Espresso is a powerful, open-source testing framework developed by Google specifically for Android applications. It provides a simple yet expressive API for writing concise and reliable UI tests that simulate user interactions and verify the behavior of your app's interface. Unlike older Android testing frameworks, Espresso operates directly on the application under test, running on the same instrumentation thread, which makes it remarkably fast and eliminates flaky test behavior caused by timing issues.

The core philosophy behind Espresso is threefold:

Espresso is part of the Android Testing Support Library and integrates seamlessly with AndroidX Test, JUnit4, and other testing tools. It is the recommended choice for writing UI tests that validate your app's critical user flows.

Why Espresso Matters

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

UI testing has historically been challenging on Android. Fragile tests, unpredictable timing, and difficult-to-maintain test suites plagued development teams. Espresso solves these problems in several critical ways:

Eliminates Flaky Tests

Traditional UI tests often fail intermittently due to timing issues — a view hasn't appeared yet, an animation is still running, or data is still loading. Espresso's synchronization guarantees that each action only proceeds when the app is idle, effectively eliminating this entire category of flakiness.

Runs at Production Speed

Because Espresso runs directly in the instrumentation thread alongside your application code, tests execute at near-production speed. A full test suite that might take minutes with other frameworks can complete in seconds with Espresso.

Improves Code Quality

Writing Espresso tests often reveals architectural problems. If a screen is difficult to test, it's likely also difficult to maintain. Espresso encourages developers to build modular, testable UI components with clear boundaries — patterns that benefit the entire codebase.

Catches Regressions Early

Espresso tests run on every CI build, catching regressions before they reach users. A well-maintained Espresso suite acts as a safety net, giving teams the confidence to refactor and ship quickly without manual QA bottlenecks.

Setting Up Espresso in Your Project

To use Espresso, add the following dependencies to your module-level build.gradle file (or the equivalent in build.gradle.kts for Kotlin DSL):

// build.gradle (Groovy DSL)
android {
    defaultConfig {
        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }
}

dependencies {
    // Core Espresso
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
    
    // Espresso extensions (optional but highly recommended)
    androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.5.1'
    androidTestImplementation 'androidx.test.espresso:espresso-intents:3.5.1'
    androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.5.1'
    
    // AndroidX Test JUnit runner and rules
    androidTestImplementation 'androidx.test.ext:junit:1.1.5'
    androidTestImplementation 'androidx.test:rules:1.5.0'
    
    // Optional: For RecyclerView actions
    androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.5.1'
}
// build.gradle.kts (Kotlin DSL)
dependencies {
    androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
    androidTestImplementation("androidx.test.espresso:espresso-contrib:3.5.1")
    androidTestImplementation("androidx.test.espresso:espresso-intents:3.5.1")
    androidTestImplementation("androidx.test.espresso:espresso-idling-resource:3.5.1")
    androidTestImplementation("androidx.test.ext:junit:1.1.5")
    androidTestImplementation("androidx.test:rules:1.5.0")
}

Note: The espresso-contrib library requires that you exclude conflicting transitive dependencies if you're using Material Components. Add this exclusion block inside your dependencies:

androidTestImplementation('androidx.test.espresso:espresso-contrib:3.5.1') {
    exclude group: 'org.checkerframework'
    exclude group: 'com.google.code.findbugs'
    exclude module: 'espresso-idling-resource'
}

Place all Espresso test files in the src/androidTest/java/ directory. Tests run on an Android device or emulator via the connectedAndroidTest Gradle task.

Core Components of Espresso

Espresso's API revolves around three fundamental building blocks that form a clear, readable testing pipeline.

onView() — Finding UI Elements

The onView() method is your entry point into the Espresso world. It accepts a ViewMatcher that identifies which view on screen you want to interact with. If multiple views match, Espresso throws an AmbiguousViewMatcherException. If no view matches, you get a NoMatchingViewException.

Common built-in matchers from the ViewMatchers class:

// Match by resource ID
onView(withId(R.id.login_button))

// Match by exact text
onView(withText("Login"))

// Match by text containing a substring
onView(withText(containsString("Welcome")))

// Match by class type
onView(withClassName(endsWith("EditText")))

// Match by content description (for accessibility)
onView(withContentDescription("Navigate to settings"))

// Match a view that is a child of another view
onView(allOf(
    withId(R.id.email_input),
    isDescendantOfA(withId(R.id.login_form))
))

// Combine multiple matchers with allOf (AND logic)
onView(allOf(
    withId(R.id.submit_button),
    isEnabled(),
    isDisplayed()
))

// Use anyOf for OR logic
onView(anyOf(
    withText("OK"),
    withText("Accept")
))

ViewActions — Performing Actions on Views

Once you've located a view with onView(), you call .perform() to execute actions on it. Espresso ships with a rich set of predefined actions in the ViewActions class:

// Click a button
onView(withId(R.id.login_button)).perform(click())

// Type text into an EditText (clears existing text first)
onView(withId(R.id.email_input)).perform(
    typeText("user@example.com")
)

// Replace existing text
onView(withId(R.id.email_input)).perform(
    replaceText("new_user@example.com")
)

// Clear text
onView(withId(R.id.email_input)).perform(clearText())

// Scroll to a view (useful in ScrollViews and RecyclerViews)
onView(withId(R.id.deeply_nested_button)).perform(scrollTo(), click())

// Double-click
onView(withId(R.id.photo_thumb)).perform(doubleClick())

// Long press
onView(withId(R.id.list_item)).perform(longClick())

// Press a specific key on the soft keyboard
onView(withId(R.id.search_field)).perform(
    typeText("Espresso"),
    pressKey(KeyEvent.KEYCODE_ENTER)
)

// Close soft keyboard
onView(withId(R.id.password_input)).perform(
    typeText("secret123"),
    closeSoftKeyboard()
)

// Swipe actions
onView(withId(R.id.swipeable_card)).perform(swipeLeft())
onView(withId(R.id.swipeable_card)).perform(swipeRight())
onView(withId(R.id.swipeable_card)).perform(swipeUp())
onView(withId(R.id.swipeable_card)).perform(swipeDown())

You can chain multiple actions in a single perform() call. Espresso executes them sequentially and waits for each to complete before starting the next:

onView(withId(R.id.comment_box))
    .perform(
        scrollTo(),
        clearText(),
        typeText("Great article!"),
        closeSoftKeyboard()
    )

ViewAssertions — Verifying View State

After performing actions, you verify the results using .check() with ViewAssertions. Assertions confirm that your UI is in the expected state:

// Check exact text match
onView(withId(R.id.welcome_message))
    .check(matches(withText("Welcome, John!")))

// Check that text contains a substring
onView(withId(R.id.error_text))
    .check(matches(withText(containsString("Invalid email"))))

// Check that a view is displayed on screen
onView(withId(R.id.success_banner))
    .check(matches(isDisplayed()))

// Check that a view is NOT displayed
onView(withId(R.id.loading_spinner))
    .check(matches(not(isDisplayed())))

// Check that a view is enabled/disabled
onView(withId(R.id.next_button))
    .check(matches(isEnabled()))
onView(withId(R.id.next_button))
    .check(matches(not(isEnabled())))

// Check that a view has focus
onView(withId(R.id.email_input))
    .check(matches(hasFocus()))

// Check that a TextView is ellipsized
onView(withId(R.id.title_text))
    .check(matches(isEllipsized()))

// Check compound checks
onView(withId(R.id.submit_button))
    .check(matches(allOf(
        isDisplayed(),
        isEnabled(),
        withText("Submit")
    )))

// Check that a view does NOT exist (use doesNotExist matcher)
onView(withId(R.id.error_panel))
    .check(matches(not(isDisplayed())))

// For checking views that should be completely gone from hierarchy:
// Use doesNotExist() — note: this is a ViewAssertion, not a matcher
onView(withId(R.id.deleted_item_view))
    .check(doesNotExist())

Writing Your First Complete Espresso Test

Let's build a complete test for a simple login screen. The app has two EditText fields (email and password), a Login button, and an error TextView that appears when login fails.

package com.example.app.test

import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.*
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class LoginScreenTest {

    // Launch the activity before each test
    @get:Rule
    val activityRule = ActivityScenarioRule(LoginActivity::class.java)

    @Test
    fun login_withValidCredentials_navigatesToHomeScreen() {
        // Type valid email
        onView(withId(R.id.email_input))
            .perform(typeText("valid_user@example.com"))

        // Type valid password
        onView(withId(R.id.password_input))
            .perform(typeText("password123"))

        // Close keyboard and click login
        onView(withId(R.id.password_input))
            .perform(closeSoftKeyboard())
        onView(withId(R.id.login_button))
            .perform(click())

        // Verify we navigated to home screen by checking for its title
        onView(withId(R.id.home_title))
            .check(matches(isDisplayed()))
        onView(withId(R.id.home_title))
            .check(matches(withText("Welcome")))
    }

    @Test
    fun login_withInvalidEmail_showsErrorMessage() {
        // Type invalid email (missing @ symbol)
        onView(withId(R.id.email_input))
            .perform(typeText("invalid_email_no_at"))

        // Type some password
        onView(withId(R.id.password_input))
            .perform(typeText("password123"), closeSoftKeyboard())

        // Click login
        onView(withId(R.id.login_button))
            .perform(click())

        // Verify error message appears
        onView(withId(R.id.error_text))
            .check(matches(isDisplayed()))
        onView(withId(R.id.error_text))
            .check(matches(withText(containsString("Invalid email"))))
    }

    @Test
    fun login_withEmptyFields_showsRequiredFieldError() {
        // Leave fields empty and click login
        onView(withId(R.id.login_button))
            .perform(click())

        // Verify both fields show error indicators
        onView(withId(R.id.email_input))
            .check(matches(hasErrorText("Email is required")))
        onView(withId(R.id.password_input))
            .check(matches(hasErrorText("Password is required")))
    }

    @Test
    fun login_buttonIsDisabled_whenFieldsAreEmpty() {
        // Verify button starts disabled
        onView(withId(R.id.login_button))
            .check(matches(not(isEnabled())))

        // Fill in email only
        onView(withId(R.id.email_input))
            .perform(typeText("user@example.com"))

        // Button should still be disabled (password empty)
        onView(withId(R.id.login_button))
            .check(matches(not(isEnabled())))

        // Fill in password
        onView(withId(R.id.password_input))
            .perform(typeText("password123"))

        // Now button should be enabled
        onView(withId(R.id.login_button))
            .check(matches(isEnabled()))
    }
}

This test class demonstrates several important patterns: using ActivityScenarioRule to manage the activity lifecycle, writing focused test methods that each verify one behavior, and using the onView-perform-check pipeline for clear, readable assertions.

Advanced Espresso Techniques

Testing RecyclerView Interactions

RecyclerView is the most common list component in Android, and Espresso provides specialized tools for testing it through the espresso-contrib library. You can scroll to items, click on specific positions, and verify item contents:

package com.example.app.test

import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class ProductListTest {

    @get:Rule
    val activityRule = ActivityScenarioRule(ProductListActivity::class.java)

    @Test
    fun scrollToItemAndClick_navigatesToDetail() {
        // Scroll to the 42nd item and click it
        onView(withId(R.id.product_recycler_view))
            .perform(RecyclerViewActions.scrollToPosition(42))
        onView(withId(R.id.product_recycler_view))
            .perform(RecyclerViewActions.actionOnItemAtPosition(
                42,
                click()
            ))

        // Verify detail screen opened
        onView(withId(R.id.product_detail_title))
            .check(matches(isDisplayed()))
    }

    @Test
    fun checkItemContents_atSpecificPosition() {
        // Scroll to position 5 and verify text content
        onView(withId(R.id.product_recycler_view))
            .perform(RecyclerViewActions.scrollToPosition(5))

        // Check the name TextView inside the item at position 5
        onView(withId(R.id.product_recycler_view))
            .perform(RecyclerViewActions.scrollToHolder(
                hasDescendant(withText("Premium Headphones"))
            ))

        // Now verify it's displayed
        onView(withText("Premium Headphones"))
            .check(matches(isDisplayed()))
    }

    @Test
    fun clickOnChildView_withinRecyclerViewItem() {
        // Click a specific button inside the 10th item
        onView(withId(R.id.product_recycler_view))
            .perform(RecyclerViewActions.actionOnItemAtPosition(
                10,
                RecyclerViewChildActions.clickOnChild(R.id.add_to_cart_button)
            ))

        // Verify cart badge updated
        onView(withId(R.id.cart_badge))
            .check(matches(withText("1")))
    }
}

Testing Intents and Navigation

Espresso Intents allows you to validate outgoing intents and stub intent responses, enabling you to test navigation flows and external app interactions without actually launching other activities:

package com.example.app.test

import android.app.Instrumentation
import android.content.Intent
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.Intents.intended
import androidx.test.espresso.intent.Intents.intending
import androidx.test.espresso.intent.matcher.IntentMatchers
import androidx.test.espresso.intent.rule.IntentsTestRule
import androidx.test.espresso.matcher.ViewMatchers.withId
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class ShareIntentTest {

    @get:Rule
    val activityRule = ActivityScenarioRule(ShareActivity::class.java)

    @Test
    fun shareButton_launchesCorrectShareIntent() {
        // Initialize Espresso Intents
        Intents.init()

        // Click the share button
        onView(withId(R.id.share_button))
            .perform(click())

        // Verify the intent was fired with correct action
        intended(
            IntentMatchers.hasAction(Intent.ACTION_CHOOSER)
        )
        intended(
            IntentMatchers.hasExtra(
                Intent.EXTRA_INTENT,
                IntentMatchers.hasAction(Intent.ACTION_SEND)
            )
        )

        // Clean up
        Intents.release()
    }

    @Test
    fun externalLink_opensCorrectUrl() {
        Intents.init()

        // Stub the intent to prevent actual browser launch
        val expectedUrl = "https://docs.example.com/help"
        intending(IntentMatchers.hasAction(Intent.ACTION_VIEW))
            .respondWith(
                Instrumentation.ActivityResult(
                    android.app.Activity.RESULT_OK,
                    null
                )
            )

        // Click the help link
        onView(withId(R.id.help_link))
            .perform(click())

        // Verify the correct URL was attempted
        intended(
            IntentMatchers.hasData(expectedUrl)
        )

        Intents.release()
    }
}

Handling Asynchronous Operations with IdlingResource

Espresso automatically synchronizes with most UI operations, but long-running background tasks (network calls, database operations, complex animations) can cause tests to proceed before the app is ready. IdlingResource solves this by telling Espresso when the app is busy and when it's idle.

First, create a custom IdlingResource for your background work:

package com.example.app.test

import androidx.test.espresso.IdlingResource
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger

/**
 * A simple IdlingResource that tracks pending background operations.
 * Register this with Espresso before tests that involve async work.
 */
class SimpleIdlingResource : IdlingResource {

    private val counter = AtomicInteger(0)
    private val resourceCallback = AtomicBoolean(false)
    @Volatile
    private var callback: IdlingResource.ResourceCallback? = null

    override fun getName(): String = "SimpleIdlingResource"

    override fun isIdleNow(): Boolean {
        val idle = counter.get() == 0
        if (idle && resourceCallback.get()) {
            callback?.onTransitionToIdle()
            resourceCallback.set(false)
        }
        return idle
    }

    override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback) {
        this.callback = callback
    }

    /**
     * Call this when a background operation starts.
     */
    fun increment() {
        counter.incrementAndGet()
        resourceCallback.set(true)
    }

    /**
     * Call this when a background operation completes.
     */
    fun decrement() {
        val remaining = counter.decrementAndGet()
        if (remaining == 0) {
            callback?.onTransitionToIdle()
        } else if (remaining < 0) {
            throw IllegalStateException("Counter went negative!")
        }
    }
}

Register the IdlingResource in your application code or expose it via a singleton so tests can access it:

// In your Application or data layer
object EspressoIdlingResource {
    @JvmStatic
    val countingIdlingResource = SimpleIdlingResource()

    fun begin() {
        countingIdlingResource.increment()
    }

    fun end() {
        countingIdlingResource.decrement()
    }
}

// Usage in your repository or ViewModel
class UserRepository {
    suspend fun fetchUserProfile(): User {
        EspressoIdlingResource.begin()
        try {
            // Perform network call
            val result = api.getUserProfile()
            return result
        } finally {
            EspressoIdlingResource.end()
        }
    }
}

In your test, register the IdlingResource before the test and unregister it after:

package com.example.app.test

import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.Espresso.registerIdlingResources
import androidx.test.espresso.Espresso.unregisterIdlingResources
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.*
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class ProfileLoadTest {

    @get:Rule
    val activityRule = ActivityScenarioRule(ProfileActivity::class.java)

    @Before
    fun registerIdlingResource() {
        registerIdlingResources(
            EspressoIdlingResource.countingIdlingResource
        )
    }

    @After
    fun unregisterIdlingResource() {
        unregisterIdlingResources(
            EspressoIdlingResource.countingIdlingResource
        )
    }

    @Test
    fun profileScreen_loadsAndDisplaysUserData() {
        // The test will automatically wait for the network call to complete
        // No need for Thread.sleep() or manual waits

        // Verify the user name is displayed after loading
        onView(withId(R.id.profile_name))
            .check(matches(withText("John Doe")))

        // Verify the email is displayed
        onView(withId(R.id.profile_email))
            .check(matches(withText("john@example.com")))
    }

    @Test
    fun pullToRefresh_reloadsProfileData() {
        // Perform swipe to refresh
        onView(withId(R.id.swipe_refresh_layout))
            .perform(swipeDown())

        // Wait for refresh to complete (IdlingResource handles this)
        // Then verify updated data appears
        onView(withId(R.id.profile_name))
            .check(matches(withText("John Doe")))
    }
}

Creating Custom ViewMatchers and ViewActions

When the built-in matchers don't cover your use case, create custom ones. Here's a custom matcher that checks if an ImageView has a specific drawable resource:

package com.example.app.test

import android.widget.ImageView
import androidx.test.espresso.matcher.BoundedMatcher
import org.hamcrest.Description
import org.hamcrest.Matcher
import org.hamcrest.TypeSafeMatcher

object CustomMatchers {

    /**
     * Matches an ImageView that has a drawable with the given resource ID.
     */
    fun hasDrawable(expectedDrawableId: Int): Matcher {
        return object : BoundedMatcher(ImageView::class.java) {

            override fun describeTo(description: Description) {
                description.appendText("has drawable with resource ID: ")
                description.appendValue(expectedDrawableId)
            }

            override fun matchesSafely(imageView: ImageView): Boolean {
                val drawable = imageView.drawable
                if (drawable == null) {
                    return false
                }
                // Compare the constant state which contains resource info
                val actualState = drawable.constantState
                val expectedDrawable = imageView.context.resources
                    .getDrawable(expectedDrawableId, null)
                val expectedState = expectedDrawable?.constantState
                return actualState != null && expectedState != null &&
                    actualState == expectedState
            }
        }
    }

    /**
     * Matches a TextView that has specific text color.
     */
    fun withTextColor(expectedColor: Int): Matcher {
        return object : BoundedMatcher(
            android.widget.TextView::class.java
        ) {
            override fun describeTo(description: Description) {
                description.appendText("with text color: ")
                description.appendValue(expectedColor)
            }

            override fun matchesSafely(textView: android.widget.TextView): Boolean {
                val actualColor = textView.currentTextColor
                return actualColor == expectedColor
            }
        }
    }

    /**
     * Matches a view that has a specific background color.
     */
    fun withBackgroundColor(expectedColor: Int): Matcher {
        return object : TypeSafeMatcher() {
            override fun describeTo(description: Description) {
                description.appendText("with background color: ")
                description.appendValue(expectedColor)
            }

            override fun matchesSafely(view: android.view.View): Boolean {
                val background = view.background
                if (background is android.graphics.drawable.ColorDrawable) {
                    val actualColor = background.color
                    return actualColor == expectedColor
                }
                return false
            }
        }
    }
}

// Usage in tests
@Test
fun profileAvatar_showsCorrectImage() {
    onView(withId(R.id.avatar_image))
        .check(matches(CustomMatchers.hasDrawable(R.drawable.default_avatar)))
}

@Test
fun errorText_isDisplayedInRed() {
    onView(withId(R.id.error_text))
        .check(matches(CustomMatchers.withTextColor(
            android.graphics.Color.parseColor("#FF0000")
        )))
}

You can also create custom ViewActions for specialized interactions:

package com.example.app.test

import android.view.View
import androidx.test.espresso.UiController
import androidx.test.espresso.ViewAction
import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import org.hamcrest.Matcher
import org.hamcrest.Matchers.allOf

object CustomActions {

    /**
     * Clicks a view at a specific coordinate offset within the view.
     */
    fun clickAtPosition(x: Float, y: Float): ViewAction {
        return object : ViewAction {
            override fun getConstraints(): Matcher {
                return allOf(isDisplayed(), isAssignableFrom(View::class.java))
            }

            override fun getDescription(): String {
                return "click at position ($x, $y)"
            }

            override fun perform(uiController: UiController, view: View) {
                val screenCoordinates = IntArray(2)
                view.getLocationOnScreen(screenCoordinates)
                val screenX = screenCoordinates[0] + x
                val screenY = screenCoordinates[1] + y

                val motionSpec = android.view.MotionEvent.obtain(
                    android.os.SystemClock.uptimeMillis(),
                    android.os.SystemClock.uptimeMillis(),
                    android.view.MotionEvent.ACTION_DOWN,
                    screenX,
                    screenY,
                    0
                )
                view.dispatchTouchEvent(motionSpec)

                // Wait a bit, then send ACTION_UP
                uiController.loopMainThreadForAtLeast(100)

                val upSpec = android.view.MotionEvent.obtain(
                    android.os.SystemClock.uptimeMillis(),
                    android.os.SystemClock.uptimeMillis(),
                    android.view.MotionEvent.ACTION_UP,
                    screenX,
                    screenY,
                    0
                )
                view.dispatchTouchEvent(upSpec)
                motionSpec.recycle()
                upSpec.recycle()
            }
        }
    }

    /**
     * Sets a SeekBar to a specific progress value.
     */
    fun setSeekBarProgress(progress: Int): ViewAction {
        return object : ViewAction {
            override fun getConstraints(): Matcher {
                return allOf(
                    isDisplayed(),
                    isAssignableFrom(android.widget.SeekBar::class.java)
                )
            }

            override fun getDescription(): String {
                return "set SeekBar progress to $progress"
            }

            override fun perform(uiController: UiController, view: View) {
                val seekBar = view as android.widget.SeekBar
                seekBar.progress = progress
            }
        }
    }
}

// Usage in tests
@Test
fun canvas_tapAtPosition_drawsDot() {
    onView(withId(R.id.drawing_canvas))
        .perform(CustomActions.clickAtPosition(150f, 300f))
    
    // Verify dot appeared
    onView(withId(R.id.dot_indicator))
        .check(matches(isDisplayed()))
}

@Test
fun seekBar_setToMiddle_showsCorrectValue() {
    onView(withId(R.id.brightness_seekbar))
        .perform(CustomActions.setSeekBarProgress(50))
    
    onView(withId(R.id.brightness_value_text))
        .check(matches(withText("50%")))
}

Best Practices for Espresso Testing

Keep Tests Focused and Independent

Each test should verify one behavior or user flow. Avoid creating long, sequential tests that depend on prior state. Use the ActivityScenarioRule (or the older ActivityTestRule) to get a fresh activity instance for every test method, ensuring complete isolation.

Use Resource IDs Whenever Possible

Rely on withId(R.id.some_view) as your primary matcher. Resource IDs are stable across refactoring, unlike text content which may change frequently or vary by locale. Reserve text-based matchers for verifying dynamic content.

Create a Test Helper or Page Object Pattern

For complex screens, encapsulate Espresso interactions in a page object or helper class. This keeps tests readable and DRY:

// Page object for LoginScreen
class LoginScreen {
    fun typeEmail(email: String): LoginScreen {
        onView(withId(R.id.email_input))
            .perform(typeText(email))
        return this
    }

    fun typePassword(password: String): LoginScreen {
        onView(withId(R.id.password_input))
            .perform(typeText(password), closeSoftKeyboard())
        return this
    }

    fun clickLogin(): LoginScreen {
        onView(withId(R.id.login_button))
            .perform(click())
        return this
    }

    fun verifyErrorShown(errorText: String): LoginScreen {
        onView(withId(R.id.error_text))
            .check(matches(withText(containsString(errorText))))
        return this
    }

    fun verifyHomeScreenVisible(): LoginScreen {
        onView(withId(R.id.home_title))
            .check(matches(isDisplayed()))
        return this
    }
}

// Usage in test
@Test
fun login_withInvalidEmail_showsError() {
    LoginScreen()
        .typeEmail("invalid_email")
        .typePassword("password123")
        .clickLogin()
        .verifyErrorShown("Invalid email")
}

Use IdlingResource for All Async Work

Never use Thread.sleep() or Thread.wait() in Espresso tests. These create fragile, slow tests. Instead, implement IdlingResource for your background operations (network, database, image loading) and register them with Espresso. This keeps tests fast and eliminates timing flakiness.

Prefer Realistic Test Data

Use production-like data in your tests. If your app displays names, use realistic names like "John Smith" rather than "test" or "asdf." Realistic data catches edge cases (long names, special characters, RTL text) that synthetic data misses.

Run Tests on Multiple Configurations

Execute your Espresso suite on different screen sizes, API levels, and locales. Use connectedAndroidTest with multiple emulators in CI. This catches layout issues, resource mismatches, and localization problems early.

Organize Tests Logically

Group tests by screen or feature in separate test classes. Name test methods clearly using the pattern: feature_condition_expectedBehavior. For example: login_withExpiredToken_redirectsToLoginScreen. This makes test reports immediately understandable.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles