Skip to content
All posts
March 17, 20263 min read

Android Espresso Testing: Patterns That Actually Work in Production

Espresso is powerful but has sharp edges. Most guides show the happy path. Here's how to handle the real problems: async operations, Hilt integration, custom view matchers, and keeping tests from becoming a maintenance nightmare.

AndroidTestingKotlin
Share:

Espresso gets blamed for being slow and flaky. Most of the time, the tool isn't the problem — the patterns are. Correct Espresso patterns produce fast, reliable UI tests.

Here's what actually works in production test suites.


The Basics That Everyone Gets Wrong

Wrong: using

code
Thread.sleep()
to wait for async operations.

kotlin
// Bad — arbitrary wait, brittle
onView(withId(R.id.save_button)).perform(click())
Thread.sleep(2000) // Hope 2s is enough
onView(withId(R.id.success_message)).check(matches(isDisplayed()))

Right: use IdlingResource.

Espresso has a built-in synchronization mechanism. When registered, Espresso waits automatically for the IdlingResource to become idle:

kotlin
// For coroutines, use a CoroutineIdlingResource or just structure tests to avoid this
// For simple cases, use ViewActions with explicit waits:
fun waitUntilVisible(viewId: Int, timeout: Long = 5000) {
    val endTime = System.currentTimeMillis() + timeout
    while (System.currentTimeMillis() < endTime) {
        try {
            onView(withId(viewId)).check(matches(isDisplayed()))
            return
        } catch (e: Exception) {
            Thread.sleep(50)
        }
    }
    throw AssertionError("View $viewId not visible within ${timeout}ms")
}

Page Object Pattern

Direct Espresso calls in tests become hard to maintain when the UI changes. Page objects encapsulate the selectors:

kotlin
class LoginPage {
    fun enterEmail(email: String): LoginPage {
        onView(withId(R.id.email_input)).perform(
            clearText(), typeText(email), closeSoftKeyboard()
        )
        return this
    }
    
    fun enterPassword(password: String): LoginPage {
        onView(withId(R.id.password_input)).perform(
            clearText(), typeText(password), closeSoftKeyboard()
        )
        return this
    }
    
    fun submit(): HomePage {
        onView(withId(R.id.login_button)).perform(click())
        return HomePage()
    }
    
    fun verifyErrorMessage(message: String): LoginPage {
        onView(withId(R.id.error_text)).check(matches(withText(message)))
        return this
    }
}

// Test — clean, readable, resilient to selector changes
@Test
fun `invalid credentials show error message`() {
    LoginPage()
        .enterEmail("wrong@example.com")
        .enterPassword("wrongpassword")
        .submit()
        .verifyErrorMessage("Invalid credentials") // Wait — submit returns HomePage
        // Actually stay on LoginPage if login fails
}

When the login button's ID changes, you update it once in

code
LoginPage
, not in every test.


Testing RecyclerView

RecyclerView needs

code
RecyclerViewActions
:

kotlin
// Click item at position
onView(withId(R.id.task_list))
    .perform(RecyclerViewActions.actionOnItemAtPosition<TaskViewHolder>(0, click()))

// Click item with specific text
onView(withId(R.id.task_list))
    .perform(RecyclerViewActions.actionOnItem<TaskViewHolder>(
        hasDescendant(withText("Buy groceries")),
        click()
    ))

// Scroll to item
onView(withId(R.id.task_list))
    .perform(RecyclerViewActions.scrollTo<TaskViewHolder>(
        hasDescendant(withText("Far down item"))
    ))

// Check item count
onView(withId(R.id.task_list))
    .check(matches(hasMinimumChildCount(3)))

Custom ViewMatchers

When built-in matchers don't work:

kotlin
// Match a view with specific background tint
fun hasBackgroundTint(@ColorInt color: Int): Matcher<View> {
    return object : BoundedMatcher<View, ImageView>(ImageView::class.java) {
        override fun describeTo(description: Description) {
            description.appendText("has background tint: $color")
        }
        
        override fun matchesSafely(view: ImageView): Boolean {
            val tint = view.backgroundTintList ?: return false
            return tint.defaultColor == color
        }
    }
}

// Usage
onView(withId(R.id.status_icon))
    .check(matches(hasBackgroundTint(Color.GREEN)))

Hilt Integration With Espresso

kotlin
@HiltAndroidTest
class LoginScreenTest {
    @get:Rule(order = 0)
    val hiltRule = HiltAndroidRule(this)
    
    @get:Rule(order = 1)
    val activityRule = ActivityScenarioRule(MainActivity::class.java)
    
    @Inject
    lateinit var authRepository: AuthRepository
    
    @Before
    fun setup() {
        hiltRule.inject()
        // Seed test data via injected fake repository
        (authRepository as FakeAuthRepository).addUser("test@example.com", "password123")
    }
    
    @Test
    fun `login with valid credentials navigates to home`() {
        onView(withId(R.id.email_input)).perform(typeText("test@example.com"), closeSoftKeyboard())
        onView(withId(R.id.password_input)).perform(typeText("password123"), closeSoftKeyboard())
        onView(withId(R.id.login_button)).perform(click())
        
        onView(withId(R.id.home_screen)).check(matches(isDisplayed()))
    }
}

Handling Dialogs

kotlin
// System dialogs (permissions)
device.findObject(UiSelector().text("Allow")).click()

// App dialogs (AlertDialog)
onView(withText("Delete")).inRoot(isDialog()).perform(click())

// Dismiss dialog
pressBack()

Test Rule Order

The order of test rules matters with Hilt:

kotlin
// Rule order is critical
@get:Rule(order = 0) // Hilt must initialize first
val hiltRule = HiltAndroidRule(this)

@get:Rule(order = 1) // Then the Activity starts (with Hilt injected)
val activityRule = ActivityScenarioRule(MainActivity::class.java)

If Hilt rule isn't first, the Activity starts without Hilt injected and you get cryptic errors.


Espresso vs Compose Testing

If your app is using Compose (it should be), prefer Compose testing APIs over Espresso for Compose UI:

FeatureEspressoCompose Testing
Find by text
code
withText()
code
onNodeWithText()
Find by ID
code
withId()
code
onNodeWithTag()
Click
code
.perform(click())
code
.performClick()
Assert visible
code
isDisplayed()
code
assertIsDisplayed()
AsyncIdlingResource
code
waitUntil {}

Espresso still applies for non-Compose UI and system-level interactions.


Takeaways

  • Never use
    code
    Thread.sleep()
    — use IdlingResource or polling helpers
  • Page objects make tests resilient to selector changes — always worth the investment
  • code
    RecyclerViewActions
    is the correct way to interact with RecyclerView items
  • Rule order matters with Hilt — Hilt rule must initialize before the Activity rule
  • Prefer Compose testing APIs for Compose UI — they're designed for the semantic model
Share:
S

Sudarshan Chaudhari

AI Systems Builder / Product Engineer

Bangkok, Thailand

Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.

Stay updated

Get new posts on Android, Kotlin, and solo dev straight to your inbox.

Newsletter preferences

Related Apps

MyFamilyTracker

Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.

Building something? Available for Android dev and QA consulting.

Work with me

Comments — powered by Giscus

Apps tagged with this