Android Espresso Testing: ViewMatchers, IdlingResources, and Hilt Injection

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 state

ViewMatchers: 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 allOf to prevent AmbiguousViewMatcherException — 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 replaceText over clearText + typeText — it is faster and skips keyboard animation.
  • Hilt @TestInstallIn replaces 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 flaky NoMatchingViewException errors.

Read more

Start now free