Espresso Android Testing: Getting Started with UI Tests in Android Studio
Espresso is Google's official Android UI testing framework. It's built into the Android Testing Support Library, runs directly on the device or emulator, and provides a concise API for interacting with and asserting on your app's UI.
Unlike Appium or UiAutomator that use external drivers, Espresso runs in the same process as your app. This gives it two major advantages: it knows when the UI is idle (no flaky waits), and it can access app internals like view hierarchies and context directly.
Project Setup
Add Espresso to your build.gradle:
// app/build.gradle
android {
defaultConfig {
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
}
dependencies {
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test:rules:1.5.0'
}Espresso tests live in src/androidTest/java/ — the instrumented test directory that runs on a device/emulator, not on your local JVM.
Your First Espresso Test
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.*
import androidx.test.espresso.assertion.ViewAssertions.*
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 login_withValidCredentials_navigatesToDashboard() {
// Type username
onView(withId(R.id.editTextUsername))
.perform(typeText("testuser"), closeSoftKeyboard())
// Type password
onView(withId(R.id.editTextPassword))
.perform(typeText("password123"), closeSoftKeyboard())
// Click login button
onView(withId(R.id.buttonLogin))
.perform(click())
// Verify navigation to dashboard
onView(withId(R.id.dashboardTitle))
.check(matches(isDisplayed()))
}
}The three core building blocks:
onView(matcher)— find a view using a matcher.perform(action)— perform an action on the matched view.check(assertion)— assert something about the matched view
ViewMatchers: Finding Views
ViewMatchers locate views in the hierarchy:
// Find by resource ID (most common)
onView(withId(R.id.submitButton))
// Find by text
onView(withText("Submit"))
onView(withText(R.string.submit_label)) // from resources
// Partial text match
onView(withText(containsString("Submit")))
// Find by content description (accessibility label)
onView(withContentDescription("close button"))
onView(withContentDescription(R.string.close_content_description))
// Find by class
onView(isAssignableFrom(Button::class.java))
// Find by hint text (for empty EditText)
onView(withHint("Enter email address"))
// Combined matchers using allOf
onView(allOf(withId(R.id.textView), withText("Error: invalid email")))
// anyOf — matches if any condition is true
onView(anyOf(withText("OK"), withText("Accept")))
// Parent/child relationships
onView(allOf(withId(R.id.label), isDescendantOfA(withId(R.id.userCard))))ViewActions: Interacting with Views
ViewActions define what to do with the matched view:
// Clicks
onView(withId(R.id.button)).perform(click())
onView(withId(R.id.button)).perform(longClick())
onView(withId(R.id.button)).perform(doubleClick())
// Text input
onView(withId(R.id.editText)).perform(typeText("Hello"))
onView(withId(R.id.editText)).perform(typeTextIntoFocusedView("Hello"))
onView(withId(R.id.editText)).perform(clearText())
onView(withId(R.id.editText)).perform(replaceText("New text"))
// Keyboard
onView(withId(R.id.editText)).perform(closeSoftKeyboard())
onView(withId(R.id.editText)).perform(pressImeActionButton())
// Scrolling
onView(withId(R.id.scrollView)).perform(scrollTo())
onView(withText("Item 50")).perform(scrollTo())
// Swiping (in ViewPager, etc.)
onView(withId(R.id.viewPager)).perform(swipeLeft())
onView(withId(R.id.viewPager)).perform(swipeRight())
onView(withId(R.id.refreshLayout)).perform(swipeDown())
// Chaining actions
onView(withId(R.id.searchField))
.perform(click(), typeText("espresso"), pressImeActionButton())ViewAssertions: Verifying State
ViewAssertions check the state of matched views:
// Visibility
onView(withId(R.id.errorMessage)).check(matches(isDisplayed()))
onView(withId(R.id.loadingSpinner)).check(matches(not(isDisplayed())))
onView(withId(R.id.hiddenView)).check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE)))
// Text content
onView(withId(R.id.titleText)).check(matches(withText("Welcome")))
onView(withId(R.id.countLabel)).check(matches(withText(containsString("3 items"))))
// Enabled/disabled state
onView(withId(R.id.submitButton)).check(matches(isEnabled()))
onView(withId(R.id.submitButton)).check(matches(not(isEnabled())))
// Checked state (for CheckBox, RadioButton)
onView(withId(R.id.agreeCheckbox)).check(matches(isChecked()))
onView(withId(R.id.optionB)).check(matches(isNotChecked()))
// Selected (Spinner, ListView)
onView(withId(R.id.spinner)).check(matches(withSpinnerText("Option A")))
// View doesn't exist
onView(withId(R.id.errorDialog)).check(doesNotExist())
// Count of matching views
onView(withId(R.id.listItem)).check(matches(isDisplayed()))
// For counting: use ViewInteraction.check with a custom assertionActivityScenarioRule vs ActivityTestRule
ActivityTestRule is the older API (deprecated). Use ActivityScenarioRule:
// Modern approach
@get:Rule
val activityRule = ActivityScenarioRule(MainActivity::class.java)
// Access the activity in a test
@Test
fun test_withActivityAccess() {
activityRule.scenario.onActivity { activity ->
// Interact with activity directly
activity.someMethod()
}
}
// Launch with a specific Intent
@get:Rule
val activityRule = ActivityScenarioRule<MainActivity>(
Intent(ApplicationProvider.getApplicationContext(), MainActivity::class.java).apply {
putExtra("mode", "test")
}
)Testing RecyclerView
RecyclerView requires the espresso-contrib library:
androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.5.1'import androidx.test.espresso.contrib.RecyclerViewActions
// Scroll to an item
onView(withId(R.id.recyclerView))
.perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(10))
// Scroll to item matching a condition
onView(withId(R.id.recyclerView))
.perform(RecyclerViewActions.scrollTo<RecyclerView.ViewHolder>(
hasDescendant(withText("Target Item"))
))
// Click on item at position
onView(withId(R.id.recyclerView))
.perform(RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(3, click()))
// Click on an item that matches a condition
onView(withId(R.id.recyclerView))
.perform(RecyclerViewActions.actionOnItem<RecyclerView.ViewHolder>(
hasDescendant(withText("Target Item")), click()
))
// Click on a child view within an item
onView(withId(R.id.recyclerView))
.perform(RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(
2,
ViewActions.actionWithAssertions(
clickChildViewWithId(R.id.deleteButton)
)
))Testing Navigation (Jetpack Navigation)
With the Navigation component:
androidTestImplementation 'androidx.navigation:navigation-testing:2.7.6'@Test
fun clicking_navigate_button_shows_second_fragment() {
val navController = TestNavHostController(ApplicationProvider.getApplicationContext())
launchFragmentInContainer<FirstFragment>().onFragment { fragment ->
Navigation.setViewNavController(fragment.requireView(), navController)
}
onView(withId(R.id.navigateButton)).perform(click())
assertThat(navController.currentDestination?.id).isEqualTo(R.id.secondFragment)
}Running Espresso Tests
From Android Studio
Right-click on the test class or test method and select "Run 'LoginActivityTest'". Tests run on the connected device or selected emulator.
From Command Line
# Run all instrumented tests
./gradlew connectedAndroidTest
# Run specific class
./gradlew connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.example.LoginActivityTest
# Run specific method
./gradlew connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.example.LoginActivityTest#login_withValidCredentials_navigatesToDashboardTest Reports
After running, find the HTML report at:
app/build/reports/androidTests/connected/index.htmlCommon Patterns
Setup and Teardown
@RunWith(AndroidJUnit4::class)
class CheckoutTest {
@get:Rule
val activityRule = ActivityScenarioRule(CheckoutActivity::class.java)
@Before
fun setup() {
// Set up test data, mock dependencies, etc.
Intents.init() // If testing intents
}
@After
fun teardown() {
Intents.release()
}
}Testing Dialogs
@Test
fun confirmDelete_showsDialog_and_confirms() {
onView(withId(R.id.deleteButton)).perform(click())
// Dialog appeared
onView(withText("Confirm Delete?")).check(matches(isDisplayed()))
// Click the confirm button in the dialog
onView(withId(android.R.id.button1)).perform(click()) // Positive button
// Verify deletion happened
onView(withId(R.id.itemList)).check(matches(hasChildCount(0)))
}Testing Toasts
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.matcher.RootMatchers.isToast
@Test
fun saving_showsSuccessToast() {
onView(withId(R.id.saveButton)).perform(click())
onView(withText("Saved successfully"))
.inRoot(isToast())
.check(matches(isDisplayed()))
}Summary
Espresso is the right tool for Android UI testing when you need to test your own app's UI in isolation. The synchronization model (automatic waiting for the UI to be idle) eliminates the main source of flakiness in UI tests. The matcher API is expressive and the view hierarchy access is direct.
The key limitations: it can only test your own app (no cross-app interaction — that requires UiAutomator), and it requires a real device or emulator to run. For unit testing business logic, use JUnit 4 or JUnit 5 in the test/ directory (local JVM tests) instead.