Android Espresso Testing: ViewMatchers, IdlingResources, and Hilt Injection
Espresso is Android's UI testing framework, executing tests directly on a device or emulator. This guide covers the full Espresso toolkit: ViewMatchers for finding views, ViewActions for interacting, ViewAssertions for validating state, IdlingResources for async synchronization, and Hilt for injecting test dependencies.
Unit tests tell you your logic is correct. Espresso tests tell you your UI actually works on a real Android device. They are slower and harder to maintain than unit tests, but they catch a class of bugs nothing else does: lifecycle issues, fragment transitions, keyboard interactions, RecyclerView scrolling, and the subtle ways Android's view system surprises you.
The investment in good Espresso tests pays off. The key is knowing the tools well enough to write tests that are fast, stable, and easy to debug.
Setup
// app/build.gradle.kts
dependencies {
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
androidTestImplementation("androidx.test.espresso:espresso-contrib:3.6.1")
androidTestImplementation("androidx.test.espresso:espresso-intents:3.6.1")
androidTestImplementation("androidx.test:runner:1.6.1")
androidTestImplementation("androidx.test:rules:1.6.1")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("com.google.dagger:hilt-android-testing:2.51.1")
kaptAndroidTest("com.google.dagger:hilt-android-compiler:2.51.1")
}
android {
defaultConfig {
testInstrumentationRunner = "com.example.app.HiltTestRunner"
}
}The Core Pattern: onView / perform / check
Every Espresso interaction follows the same structure:
onView(matcher) // Find the view
.perform(action) // Interact with it (optional)
.check(assertion) // Assert its stateViewMatchers: Finding Views
ViewMatchers locate views in the hierarchy. You can combine them with allOf for specificity.
@Test
fun `login form shows expected elements`() {
// By resource ID (most reliable)
onView(withId(R.id.emailInput)).check(matches(isDisplayed()))
onView(withId(R.id.passwordInput)).check(matches(isDisplayed()))
onView(withId(R.id.loginButton)).check(matches(isEnabled()))
// By text
onView(withText("Sign In")).check(matches(isDisplayed()))
onView(withText(R.string.login_title)).check(matches(isDisplayed()))
// By content description (accessibility)
onView(withContentDescription("Close dialog")).check(matches(isDisplayed()))
// By hint text
onView(withHint("Enter your email")).check(matches(isDisplayed()))
// Combining matchers — more specific, less fragile
onView(allOf(
withId(R.id.loginButton),
withText("Sign In"),
isEnabled()
)).check(matches(isDisplayed()))
}Matching Views in Lists
When a matcher matches multiple views (e.g., multiple buttons with the same text), Espresso throws AmbiguousViewMatcherException. Use allOf or target a specific parent:
// Within a specific container
onView(allOf(
withId(R.id.actionButton),
isDescendantOfA(withId(R.id.cardContainer))
)).perform(click())
// At a specific position in a list
onView(allOf(
withId(R.id.itemTitle),
withParentIndex(2)
)).check(matches(withText("Third Item")))ViewActions: Interacting with Views
@Test
fun `user can fill and submit login form`() {
// Type text
onView(withId(R.id.emailInput))
.perform(typeText("alice@example.com"))
// Type text and close keyboard
onView(withId(R.id.passwordInput))
.perform(typeText("SecurePass123!"), closeSoftKeyboard())
// Clear existing text then type
onView(withId(R.id.emailInput))
.perform(clearText(), typeText("new@example.com"))
// Click
onView(withId(R.id.loginButton)).perform(click())
// Long press
onView(withId(R.id.messageItem)).perform(longClick())
// Scroll to view (if off-screen)
onView(withId(R.id.submitButton))
.perform(scrollTo(), click())
// Swipe
onView(withId(R.id.drawerLayout))
.perform(swipeRight())
// Replace text (skips animation, faster than clearText + typeText)
onView(withId(R.id.searchInput))
.perform(replaceText("kotlin testing"))
}ViewAssertions: Checking State
@Test
fun `error message appears on invalid login`() {
onView(withId(R.id.emailInput))
.perform(typeText("notanemail"), closeSoftKeyboard())
onView(withId(R.id.loginButton)).perform(click())
// Visibility
onView(withId(R.id.errorMessage)).check(matches(isDisplayed()))
onView(withId(R.id.loadingSpinner)).check(matches(not(isDisplayed())))
// Text content
onView(withId(R.id.errorMessage))
.check(matches(withText("Please enter a valid email")))
// View properties
onView(withId(R.id.loginButton)).check(matches(isEnabled()))
onView(withId(R.id.loginButton)).check(matches(not(isChecked())))
// Does not exist
onView(withId(R.id.successBanner)).check(doesNotExist())
// Custom assertion
onView(withId(R.id.emailInput)).check { view, noViewFoundException ->
noViewFoundException?.let { throw it }
val editText = view as EditText
assert(editText.error != null) { "Expected error on EditText" }
}
}RecyclerView Actions
The espresso-contrib library provides RecyclerViewActions for interacting with RecyclerViews:
@Test
fun `product list displays items and handles tap`() {
// Scroll to position
onView(withId(R.id.productList))
.perform(RecyclerViewActions.scrollToPosition<ProductViewHolder>(10))
// Click item at position
onView(withId(R.id.productList))
.perform(RecyclerViewActions.actionOnItemAtPosition<ProductViewHolder>(0, click()))
// Scroll to and click an item matching a matcher
onView(withId(R.id.productList))
.perform(RecyclerViewActions.scrollTo<ProductViewHolder>(
hasDescendant(withText("Kotlin Book"))
))
onView(withId(R.id.productList))
.perform(RecyclerViewActions.actionOnItem<ProductViewHolder>(
hasDescendant(withText("Kotlin Book")),
click()
))
// Verify item count
onView(withId(R.id.productList))
.check(RecyclerViewAssertions.matches(hasItemCount(20)))
}IdlingResources: Synchronizing with Async Operations
Espresso automatically waits for the main thread to be idle and for AsyncTask to finish. But modern apps use coroutines, Retrofit, and custom thread pools. For these, you need IdlingResource.
An IdlingResource tells Espresso "I am busy right now, wait before running the next action."
CountingIdlingResource
The simplest approach: increment when work starts, decrement when it finishes.
// In your app module (not test code)
object EspressoIdlingResource {
private const val RESOURCE = "GLOBAL"
@JvmField
val countingIdlingResource = CountingIdlingResource(RESOURCE)
fun increment() {
countingIdlingResource.increment()
}
fun decrement() {
if (!countingIdlingResource.isIdleNow) {
countingIdlingResource.decrement()
}
}
}
// In your ViewModel or Repository
class ProductRepository(private val api: ProductApi) {
suspend fun getProducts(): List<Product> {
EspressoIdlingResource.increment()
return try {
api.getProducts()
} finally {
EspressoIdlingResource.decrement()
}
}
}// In your test
@RunWith(AndroidJUnit4::class)
class ProductListTest {
@get:Rule
val activityRule = ActivityScenarioRule(ProductListActivity::class.java)
@Before
fun registerIdlingResource() {
IdlingRegistry.getInstance()
.register(EspressoIdlingResource.countingIdlingResource)
}
@After
fun unregisterIdlingResource() {
IdlingRegistry.getInstance()
.unregister(EspressoIdlingResource.countingIdlingResource)
}
@Test
fun `products load and display after network call`() {
// Espresso automatically waits for the IdlingResource to be idle
// before proceeding with assertions
onView(withId(R.id.productList))
.check(matches(isDisplayed()))
onView(withId(R.id.productList))
.check(RecyclerViewAssertions.matches(hasItemCount(greaterThan(0))))
}
}OkHttp3 IdlingResource
For Retrofit/OkHttp, use the dedicated idling resource from espresso-idling-resource:
dependencies {
androidTestImplementation("com.jakewharton.espresso:okhttp3-idling-resource:1.0.0")
}
class NetworkTest {
private val okHttpClient = OkHttpClient()
private val idlingResource = OkHttp3IdlingResource.create("okhttp", okHttpClient)
@Before
fun register() = IdlingRegistry.getInstance().register(idlingResource)
@After
fun unregister() = IdlingRegistry.getInstance().unregister(idlingResource)
}Hilt Injection in Tests
Without Hilt, you either use the real dependencies (slow, flaky) or write complex test application subclasses. Hilt makes this clean.
HiltTestRunner
// androidTest/HiltTestRunner.kt
class HiltTestRunner : AndroidJUnitRunner() {
override fun newApplication(
cl: ClassLoader?,
className: String?,
context: Context?
): Application {
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
}Test Module
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [NetworkModule::class]
)
object FakeNetworkModule {
@Provides
@Singleton
fun provideProductApi(): ProductApi = FakeProductApi()
@Provides
@Singleton
fun provideUserRepository(): UserRepository = FakeUserRepository()
}The Test
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class ProductListActivityTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val activityRule = ActivityScenarioRule(ProductListActivity::class.java)
@Inject
lateinit var fakeApi: ProductApi // Injected fake, not the real Retrofit API
@Before
fun setUp() {
hiltRule.inject()
}
@Test
fun `displays products from repository`() {
(fakeApi as FakeProductApi).setProducts(listOf(
Product(id = 1, name = "Kotlin in Action", price = 39.99),
Product(id = 2, name = "Effective Kotlin", price = 34.99)
))
onView(withText("Kotlin in Action")).check(matches(isDisplayed()))
onView(withText("Effective Kotlin")).check(matches(isDisplayed()))
}
@Test
fun `shows error state when network fails`() {
(fakeApi as FakeProductApi).setError(IOException("No internet"))
onView(withId(R.id.errorView)).check(matches(isDisplayed()))
onView(withText(R.string.network_error_message)).check(matches(isDisplayed()))
onView(withId(R.id.retryButton)).check(matches(isEnabled()))
}
}Intent Testing with Espresso Intents
Use espresso-intents to verify that your app sends the right intents, or to stub intents from external apps:
@RunWith(AndroidJUnit4::class)
class ShareActivityTest {
@get:Rule
val intentsRule = IntentsRule()
@get:Rule
val activityRule = ActivityScenarioRule(ProductDetailActivity::class.java)
@Test
fun `share button fires correct intent`() {
onView(withId(R.id.shareButton)).perform(click())
intended(allOf(
hasAction(Intent.ACTION_SEND),
hasType("text/plain"),
hasExtra(Intent.EXTRA_TEXT, containsString("helpmetest.com"))
))
}
@Test
fun `camera button launches camera app`() {
// Stub the camera intent to avoid launching a real camera
intending(hasAction(MediaStore.ACTION_IMAGE_CAPTURE))
.respondWith(ActivityResult(Activity.RESULT_OK, null))
onView(withId(R.id.cameraButton)).perform(click())
// Verify the intent was sent
intended(hasAction(MediaStore.ACTION_IMAGE_CAPTURE))
}
}Page Object Pattern
For maintainable Espresso tests, wrap interactions in Page Objects. This separates test logic from UI selectors:
class LoginPage {
fun enterEmail(email: String): LoginPage {
onView(withId(R.id.emailInput)).perform(replaceText(email))
return this
}
fun enterPassword(password: String): LoginPage {
onView(withId(R.id.passwordInput))
.perform(replaceText(password), closeSoftKeyboard())
return this
}
fun clickLogin(): DashboardPage {
onView(withId(R.id.loginButton)).perform(click())
return DashboardPage()
}
fun assertErrorMessage(expected: String): LoginPage {
onView(withId(R.id.errorMessage))
.check(matches(allOf(isDisplayed(), withText(expected))))
return this
}
}
class DashboardPage {
fun assertWelcomeMessage(name: String): DashboardPage {
onView(withId(R.id.welcomeText))
.check(matches(withText("Welcome, $name!")))
return this
}
}
// Clean test using Page Objects
@Test
fun `successful login navigates to dashboard`() {
LoginPage()
.enterEmail("alice@example.com")
.enterPassword("SecurePass123!")
.clickLogin()
.assertWelcomeMessage("Alice")
}
@Test
fun `invalid credentials show error`() {
LoginPage()
.enterEmail("alice@example.com")
.enterPassword("wrongpassword")
.clickLogin()
.assertErrorMessage("Invalid email or password")
}Key Takeaways
- Use
allOfto preventAmbiguousViewMatcherException— never rely on a single matcher that could match multiple views. - Register/unregister IdlingResources in
@Before/@After— forgetting the unregister causes test suite failures. - Prefer
replaceTextoverclearText + typeText— it is faster and skips keyboard animation. - Hilt
@TestInstallInreplaces entire modules — swap out your entire network layer for fakes without touching production code. - Page Objects are not optional at scale — when the UI changes, you update one place instead of twenty tests.
closeSoftKeyboard()after typing — failing to close the keyboard hides views and causes flakyNoMatchingViewExceptionerrors.