Android Espresso Testing: UI Tests for Native Android Apps

Android Espresso Testing: UI Tests for Native Android Apps

Espresso is Google's official UI testing framework for Android. Unlike Appium, which runs as an external server, Espresso runs inside the app process. This makes it significantly faster and more reliable — no network overhead, no timing guesses, no session management.

If you're testing a native Android app, Espresso is the right tool for UI-layer tests.

Why Espresso Over Appium for Android?

Espresso Appium
Speed Fast (in-process) Slower (network round-trips)
Reliability Very high Moderate
Cross-platform Android only Android + iOS
Language Kotlin/Java Any language
Setup Gradle dependency External server + driver
Access to app internals Full None

Use Espresso when you're testing Android-only and want speed. Use Appium when you need the same tests on iOS too.

Setup

Add Espresso to your app/build.gradle:

android {
    defaultConfig {
        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }
}

dependencies {
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
    androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.5.1'
    androidTestImplementation 'androidx.test:runner:1.5.2'
    androidTestImplementation 'androidx.test:rules:1.5.0'
    androidTestImplementation 'androidx.test.ext:junit:1.1.5'
    androidTestImplementation 'androidx.test.espresso:espresso-intents:3.5.1'
}

Tests live in app/src/androidTest/java/com/example/app/. They run on a real device or emulator — they're instrumented tests, not unit tests.

The Espresso API

Espresso has three core concepts:

  1. ViewMatchers — find a view: onView(withId(R.id.submit))
  2. ViewActions — do something: .perform(click())
  3. ViewAssertions — verify state: .check(matches(isDisplayed()))
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.*

// The complete pattern: find → act → assert
onView(withId(R.id.username_field))
    .perform(typeText("user@example.com"), closeSoftKeyboard())

onView(withId(R.id.password_field))
    .perform(typeText("password123"), closeSoftKeyboard())

onView(withId(R.id.login_button))
    .perform(click())

onView(withId(R.id.welcome_message))
    .check(matches(isDisplayed()))

Writing Your First Test

// app/src/androidTest/java/com/example/app/LoginActivityTest.kt
package com.example.app

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 androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class LoginActivityTest {

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

    @Test
    fun successfulLogin_navigatesToHome() {
        onView(withId(R.id.et_email))
            .perform(typeText("user@example.com"), closeSoftKeyboard())

        onView(withId(R.id.et_password))
            .perform(typeText("password123"), closeSoftKeyboard())

        onView(withId(R.id.btn_login))
            .perform(click())

        onView(withId(R.id.tv_home_title))
            .check(matches(isDisplayed()))
    }

    @Test
    fun emptyEmail_showsValidationError() {
        onView(withId(R.id.btn_login))
            .perform(click())

        onView(withId(R.id.et_email))
            .check(matches(hasErrorText("Email is required")))
    }

    @Test
    fun invalidCredentials_showsErrorMessage() {
        onView(withId(R.id.et_email))
            .perform(typeText("wrong@example.com"), closeSoftKeyboard())

        onView(withId(R.id.et_password))
            .perform(typeText("wrongpassword"), closeSoftKeyboard())

        onView(withId(R.id.btn_login))
            .perform(click())

        onView(withText("Invalid credentials"))
            .check(matches(isDisplayed()))
    }
}

Run from command line:

./gradlew connectedAndroidTest

Or run a single test:

./gradlew connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.example.app.LoginActivityTest

ViewMatchers Reference

// By resource ID
onView(withId(R.id.my_button))

// By displayed text
onView(withText("Submit"))

// By partial text
onView(withText(containsString("Submit")))

// By content description (accessibility)
onView(withContentDescription("Close dialog"))

// By hint text
onView(withHint("Enter your email"))

// Combining matchers
onView(allOf(withId(R.id.btn_confirm), isEnabled()))

// By parent
onView(allOf(withText("OK"), isDescendantOfA(withId(R.id.dialog_buttons))))

// By position in list
onView(allOf(withId(R.id.item_title), withParentIndex(0)))

ViewActions Reference

// Typing
.perform(typeText("hello"))
.perform(replaceText("hello"))  // Replaces existing text
.perform(clearText())

// Keyboard
.perform(closeSoftKeyboard())
.perform(pressKey(KeyEvent.KEYCODE_ENTER))
.perform(pressBack())

// Clicking
.perform(click())
.perform(longClick())
.perform(doubleClick())

// Scrolling
.perform(scrollTo())  // Scroll to make element visible

// Swipe
.perform(swipeLeft())
.perform(swipeRight())
.perform(swipeUp())
.perform(swipeDown())

ViewAssertions Reference

// Visibility
.check(matches(isDisplayed()))
.check(matches(isNotDisplayed()))
.check(matches(withEffectiveVisibility(Visibility.VISIBLE)))

// State
.check(matches(isEnabled()))
.check(matches(isDisabled()))
.check(matches(isChecked()))
.check(matches(isNotChecked()))
.check(matches(isSelected()))

// Text
.check(matches(withText("Expected text")))
.check(matches(hasErrorText("Error message")))

// Count
.check(matches(hasChildCount(3)))

// Does not exist
.check(doesNotExist())

Testing RecyclerView

RecyclerView requires the espresso-contrib library:

import androidx.test.espresso.contrib.RecyclerViewActions

// Scroll to item at position
onView(withId(R.id.recycler_view))
    .perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(10))

// Click on item at position
onView(withId(R.id.recycler_view))
    .perform(RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(5, click()))

// Click on a view inside an item
onView(withId(R.id.recycler_view))
    .perform(RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(
        2,
        click() // or a custom ViewAction to click a child view
    ))

// Click item matching specific text
onView(withId(R.id.recycler_view))
    .perform(RecyclerViewActions.actionOnItem<RecyclerView.ViewHolder>(
        hasDescendant(withText("Item Title")),
        click()
    ))

Testing with Hilt (Dependency Injection)

Most modern Android apps use Hilt. To test with Hilt, replace your production modules with test doubles:

// Create a test module
@Module
@TestInstallIn(components = [SingletonComponent::class], replaces = [NetworkModule::class])
object FakeNetworkModule {
    @Provides
    @Singleton
    fun provideApiService(): ApiService = FakeApiService()
}

// Your test
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class HomeScreenTest {

    @get:Rule(order = 0)
    val hiltRule = HiltAndroidRule(this)

    @get:Rule(order = 1)
    val activityRule = ActivityScenarioRule(HomeActivity::class.java)

    @Test
    fun userList_displaysCorrectly() {
        onView(withId(R.id.recycler_view))
            .check(matches(isDisplayed()))

        // FakeApiService returns 3 users
        onView(withId(R.id.recycler_view))
            .check(matches(hasChildCount(3)))
    }
}

Testing with Coroutines

When your code uses coroutines, use runTest and the TestDispatcher:

@get:Rule
val mainDispatcherRule = MainDispatcherRule()

@Test
fun asyncDataLoad_displaysResults() = runTest {
    // Launch activity
    val scenario = ActivityScenario.launch(HomeActivity::class.java)

    // Espresso automatically waits for the UI thread to be idle
    // but for coroutines, use IdlingRegistry
    onView(withId(R.id.progress_bar))
        .check(matches(isNotDisplayed()))

    onView(withId(R.id.result_list))
        .check(matches(isDisplayed()))
}

Register an IdlingResource for async operations:

class CoroutineIdlingResource : IdlingResource {
    private var resourceCallback: IdlingResource.ResourceCallback? = null
    
    @Volatile
    var isIdle: Boolean = true
        set(value) {
            field = value
            if (value) resourceCallback?.onTransitionToIdle()
        }

    override fun getName() = "CoroutineIdlingResource"
    override fun isIdleNow() = isIdle
    override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback?) {
        resourceCallback = callback
    }
}

Espresso Intents

Test that your app starts the right intents:

import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers.*

@RunWith(AndroidJUnit4::class)
class ShareActivityTest {

    @get:Rule
    val intentsRule = IntentsRule()

    @Test
    fun shareButton_opensShareIntent() {
        onView(withId(R.id.btn_share))
            .perform(click())

        intended(hasAction(Intent.ACTION_SEND))
        intended(hasType("text/plain"))
    }

    @Test
    fun openMap_launchesMapApp() {
        // Stub out external app launch
        intending(hasAction(Intent.ACTION_VIEW))
            .respondWith(ActivityResult(Activity.RESULT_OK, null))

        onView(withId(R.id.btn_directions))
            .perform(click())

        intended(hasAction(Intent.ACTION_VIEW))
    }
}

Screenshot on Failure

Capture screenshots when tests fail:

@get:Rule
val screenshotRule = ScreenshotRule()

Or manually in a test rule:

class ScreenshotOnFailureRule : TestWatcher() {
    override fun failed(e: Throwable?, description: Description?) {
        val filename = "${description?.methodName}_failure.png"
        val bitmap = getInstrumentation().uiAutomation.takeScreenshot()
        val dir = File(getExternalStorageDirectory(), "test-screenshots")
        dir.mkdirs()
        val file = File(dir, filename)
        FileOutputStream(file).use { out ->
            bitmap.compress(Bitmap.CompressFormat.PNG, 90, out)
        }
    }
}

Running on CI

GitHub Actions with an Android emulator:

name: Espresso Tests
on: [push, pull_request]

jobs:
  instrumented-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          java-version: '17'
      - name: Enable KVM
        run: |
          echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
          sudo udevadm control --reload-rules
          sudo udevadm trigger --name-match=kvm
      - uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 30
          arch: x86_64
          script: ./gradlew connectedAndroidTest
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: espresso-test-results
          path: app/build/reports/androidTests/

Espresso Test Recorder

If you're new to Espresso, the Espresso Test Recorder in Android Studio can generate test code from your manual interactions:

  1. Open Android Studio → RunRecord Espresso Test
  2. Interact with your app on the emulator
  3. Add assertions by clicking Add Assertion
  4. Stop recording — Android Studio generates the test file

The generated code isn't always clean, but it's a fast starting point. Refactor into Page Objects before checking in.

What Espresso Doesn't Cover

Espresso handles the in-app UI layer well. It doesn't:

  • Test across multiple apps or system dialogs (use UiAutomator for those)
  • Monitor production behavior
  • Run on iOS

For production monitoring — checking that real user flows work correctly after each release — HelpMeTest provides always-on end-to-end checks without device setup or infrastructure.

Summary

  • Add: espresso-core + espresso-contrib in androidTestImplementation
  • Pattern: onView(matcher).perform(action).check(assertion)
  • RecyclerView: Use RecyclerViewActions from espresso-contrib
  • Hilt: Replace modules with @TestInstallIn test fakes
  • Async: Use IdlingResource so Espresso waits for coroutines/async work
  • CI: reactivecircus/android-emulator-runner handles emulator lifecycle in GitHub Actions

Espresso's in-process architecture makes it the fastest and most reliable option for Android UI testing. Start with your most critical user flows and expand coverage from there.

Read more