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:
- ViewMatchers — find a view:
onView(withId(R.id.submit)) - ViewActions — do something:
.perform(click()) - 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 connectedAndroidTestOr run a single test:
./gradlew connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.example.app.LoginActivityTestViewMatchers 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:
- Open Android Studio → Run → Record Espresso Test
- Interact with your app on the emulator
- Add assertions by clicking Add Assertion
- 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-contribinandroidTestImplementation - Pattern:
onView(matcher).perform(action).check(assertion) - RecyclerView: Use
RecyclerViewActionsfromespresso-contrib - Hilt: Replace modules with
@TestInstallIntest fakes - Async: Use
IdlingResourceso Espresso waits for coroutines/async work - CI:
reactivecircus/android-emulator-runnerhandles 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.