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.
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.
On this page
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.
Wrong: using Thread.sleep()
// 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:
// 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")
}Direct Espresso calls in tests become hard to maintain when the UI changes. Page objects encapsulate the selectors:
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
LoginPageRecyclerView needs
RecyclerViewActions// 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)))When built-in matchers don't work:
// 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)))@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()))
}
}// System dialogs (permissions)
device.findObject(UiSelector().text("Allow")).click()
// App dialogs (AlertDialog)
onView(withText("Delete")).inRoot(isDialog()).perform(click())
// Dismiss dialog
pressBack()The order of test rules matters with Hilt:
// 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.
If your app is using Compose (it should be), prefer Compose testing APIs over Espresso for Compose UI:
| Feature | Espresso | Compose Testing |
|---|---|---|
| Find by text | code | code |
| Find by ID | code | code |
| Click | code | code |
| Assert visible | code | code |
| Async | IdlingResource | code |
Espresso still applies for non-Compose UI and system-level interactions.
Thread.sleep()RecyclerViewActionsSudarshan 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.
Related Posts
Related Apps
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 meComments — powered by Giscus
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
ReadPrivate dream journal — structured entry capture, pattern tagging, and optional Claude-powered insight generation. All data stays on-device by default.
ReadWorkout tracker — exercise logging with set/rep/weight history, goal progression, and local Room DB persistence. No account, no cloud sync required.
Read