What is Espresso?
Espresso is a powerful, open-source testing framework developed by Google specifically for Android applications. It provides a simple yet expressive API for writing concise and reliable UI tests that simulate user interactions and verify the behavior of your app's interface. Unlike older Android testing frameworks, Espresso operates directly on the application under test, running on the same instrumentation thread, which makes it remarkably fast and eliminates flaky test behavior caused by timing issues.
The core philosophy behind Espresso is threefold:
- Automatic synchronization — Espresso automatically waits for UI events, animations, and data loading to complete before proceeding with the next test action
- Simple, fluent API — The onView(), perform(), and check() pattern creates readable, self-documenting tests
- Reliability — By running synchronously and handling timing internally, Espresso eliminates the need for sleep() calls or manual wait conditions
Espresso is part of the Android Testing Support Library and integrates seamlessly with AndroidX Test, JUnit4, and other testing tools. It is the recommended choice for writing UI tests that validate your app's critical user flows.
Why Espresso Matters
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →UI testing has historically been challenging on Android. Fragile tests, unpredictable timing, and difficult-to-maintain test suites plagued development teams. Espresso solves these problems in several critical ways:
Eliminates Flaky Tests
Traditional UI tests often fail intermittently due to timing issues — a view hasn't appeared yet, an animation is still running, or data is still loading. Espresso's synchronization guarantees that each action only proceeds when the app is idle, effectively eliminating this entire category of flakiness.
Runs at Production Speed
Because Espresso runs directly in the instrumentation thread alongside your application code, tests execute at near-production speed. A full test suite that might take minutes with other frameworks can complete in seconds with Espresso.
Improves Code Quality
Writing Espresso tests often reveals architectural problems. If a screen is difficult to test, it's likely also difficult to maintain. Espresso encourages developers to build modular, testable UI components with clear boundaries — patterns that benefit the entire codebase.
Catches Regressions Early
Espresso tests run on every CI build, catching regressions before they reach users. A well-maintained Espresso suite acts as a safety net, giving teams the confidence to refactor and ship quickly without manual QA bottlenecks.
Setting Up Espresso in Your Project
To use Espresso, add the following dependencies to your module-level build.gradle file (or the equivalent in build.gradle.kts for Kotlin DSL):
// build.gradle (Groovy DSL)
android {
defaultConfig {
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
}
dependencies {
// Core Espresso
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
// Espresso extensions (optional but highly recommended)
androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.5.1'
// AndroidX Test JUnit runner and rules
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test:rules:1.5.0'
// Optional: For RecyclerView actions
androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.5.1'
}
// build.gradle.kts (Kotlin DSL)
dependencies {
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation("androidx.test.espresso:espresso-contrib:3.5.1")
androidTestImplementation("androidx.test.espresso:espresso-intents:3.5.1")
androidTestImplementation("androidx.test.espresso:espresso-idling-resource:3.5.1")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test:rules:1.5.0")
}
Note: The espresso-contrib library requires that you exclude conflicting transitive dependencies if you're using Material Components. Add this exclusion block inside your dependencies:
androidTestImplementation('androidx.test.espresso:espresso-contrib:3.5.1') {
exclude group: 'org.checkerframework'
exclude group: 'com.google.code.findbugs'
exclude module: 'espresso-idling-resource'
}
Place all Espresso test files in the src/androidTest/java/ directory. Tests run on an Android device or emulator via the connectedAndroidTest Gradle task.
Core Components of Espresso
Espresso's API revolves around three fundamental building blocks that form a clear, readable testing pipeline.
onView() — Finding UI Elements
The onView() method is your entry point into the Espresso world. It accepts a ViewMatcher that identifies which view on screen you want to interact with. If multiple views match, Espresso throws an AmbiguousViewMatcherException. If no view matches, you get a NoMatchingViewException.
Common built-in matchers from the ViewMatchers class:
// Match by resource ID
onView(withId(R.id.login_button))
// Match by exact text
onView(withText("Login"))
// Match by text containing a substring
onView(withText(containsString("Welcome")))
// Match by class type
onView(withClassName(endsWith("EditText")))
// Match by content description (for accessibility)
onView(withContentDescription("Navigate to settings"))
// Match a view that is a child of another view
onView(allOf(
withId(R.id.email_input),
isDescendantOfA(withId(R.id.login_form))
))
// Combine multiple matchers with allOf (AND logic)
onView(allOf(
withId(R.id.submit_button),
isEnabled(),
isDisplayed()
))
// Use anyOf for OR logic
onView(anyOf(
withText("OK"),
withText("Accept")
))
ViewActions — Performing Actions on Views
Once you've located a view with onView(), you call .perform() to execute actions on it. Espresso ships with a rich set of predefined actions in the ViewActions class:
// Click a button
onView(withId(R.id.login_button)).perform(click())
// Type text into an EditText (clears existing text first)
onView(withId(R.id.email_input)).perform(
typeText("user@example.com")
)
// Replace existing text
onView(withId(R.id.email_input)).perform(
replaceText("new_user@example.com")
)
// Clear text
onView(withId(R.id.email_input)).perform(clearText())
// Scroll to a view (useful in ScrollViews and RecyclerViews)
onView(withId(R.id.deeply_nested_button)).perform(scrollTo(), click())
// Double-click
onView(withId(R.id.photo_thumb)).perform(doubleClick())
// Long press
onView(withId(R.id.list_item)).perform(longClick())
// Press a specific key on the soft keyboard
onView(withId(R.id.search_field)).perform(
typeText("Espresso"),
pressKey(KeyEvent.KEYCODE_ENTER)
)
// Close soft keyboard
onView(withId(R.id.password_input)).perform(
typeText("secret123"),
closeSoftKeyboard()
)
// Swipe actions
onView(withId(R.id.swipeable_card)).perform(swipeLeft())
onView(withId(R.id.swipeable_card)).perform(swipeRight())
onView(withId(R.id.swipeable_card)).perform(swipeUp())
onView(withId(R.id.swipeable_card)).perform(swipeDown())
You can chain multiple actions in a single perform() call. Espresso executes them sequentially and waits for each to complete before starting the next:
onView(withId(R.id.comment_box))
.perform(
scrollTo(),
clearText(),
typeText("Great article!"),
closeSoftKeyboard()
)
ViewAssertions — Verifying View State
After performing actions, you verify the results using .check() with ViewAssertions. Assertions confirm that your UI is in the expected state:
// Check exact text match
onView(withId(R.id.welcome_message))
.check(matches(withText("Welcome, John!")))
// Check that text contains a substring
onView(withId(R.id.error_text))
.check(matches(withText(containsString("Invalid email"))))
// Check that a view is displayed on screen
onView(withId(R.id.success_banner))
.check(matches(isDisplayed()))
// Check that a view is NOT displayed
onView(withId(R.id.loading_spinner))
.check(matches(not(isDisplayed())))
// Check that a view is enabled/disabled
onView(withId(R.id.next_button))
.check(matches(isEnabled()))
onView(withId(R.id.next_button))
.check(matches(not(isEnabled())))
// Check that a view has focus
onView(withId(R.id.email_input))
.check(matches(hasFocus()))
// Check that a TextView is ellipsized
onView(withId(R.id.title_text))
.check(matches(isEllipsized()))
// Check compound checks
onView(withId(R.id.submit_button))
.check(matches(allOf(
isDisplayed(),
isEnabled(),
withText("Submit")
)))
// Check that a view does NOT exist (use doesNotExist matcher)
onView(withId(R.id.error_panel))
.check(matches(not(isDisplayed())))
// For checking views that should be completely gone from hierarchy:
// Use doesNotExist() — note: this is a ViewAssertion, not a matcher
onView(withId(R.id.deleted_item_view))
.check(doesNotExist())
Writing Your First Complete Espresso Test
Let's build a complete test for a simple login screen. The app has two EditText fields (email and password), a Login button, and an error TextView that appears when login fails.
package com.example.app.test
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
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 org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class LoginScreenTest {
// Launch the activity before each test
@get:Rule
val activityRule = ActivityScenarioRule(LoginActivity::class.java)
@Test
fun login_withValidCredentials_navigatesToHomeScreen() {
// Type valid email
onView(withId(R.id.email_input))
.perform(typeText("valid_user@example.com"))
// Type valid password
onView(withId(R.id.password_input))
.perform(typeText("password123"))
// Close keyboard and click login
onView(withId(R.id.password_input))
.perform(closeSoftKeyboard())
onView(withId(R.id.login_button))
.perform(click())
// Verify we navigated to home screen by checking for its title
onView(withId(R.id.home_title))
.check(matches(isDisplayed()))
onView(withId(R.id.home_title))
.check(matches(withText("Welcome")))
}
@Test
fun login_withInvalidEmail_showsErrorMessage() {
// Type invalid email (missing @ symbol)
onView(withId(R.id.email_input))
.perform(typeText("invalid_email_no_at"))
// Type some password
onView(withId(R.id.password_input))
.perform(typeText("password123"), closeSoftKeyboard())
// Click login
onView(withId(R.id.login_button))
.perform(click())
// Verify error message appears
onView(withId(R.id.error_text))
.check(matches(isDisplayed()))
onView(withId(R.id.error_text))
.check(matches(withText(containsString("Invalid email"))))
}
@Test
fun login_withEmptyFields_showsRequiredFieldError() {
// Leave fields empty and click login
onView(withId(R.id.login_button))
.perform(click())
// Verify both fields show error indicators
onView(withId(R.id.email_input))
.check(matches(hasErrorText("Email is required")))
onView(withId(R.id.password_input))
.check(matches(hasErrorText("Password is required")))
}
@Test
fun login_buttonIsDisabled_whenFieldsAreEmpty() {
// Verify button starts disabled
onView(withId(R.id.login_button))
.check(matches(not(isEnabled())))
// Fill in email only
onView(withId(R.id.email_input))
.perform(typeText("user@example.com"))
// Button should still be disabled (password empty)
onView(withId(R.id.login_button))
.check(matches(not(isEnabled())))
// Fill in password
onView(withId(R.id.password_input))
.perform(typeText("password123"))
// Now button should be enabled
onView(withId(R.id.login_button))
.check(matches(isEnabled()))
}
}
This test class demonstrates several important patterns: using ActivityScenarioRule to manage the activity lifecycle, writing focused test methods that each verify one behavior, and using the onView-perform-check pipeline for clear, readable assertions.
Advanced Espresso Techniques
Testing RecyclerView Interactions
RecyclerView is the most common list component in Android, and Espresso provides specialized tools for testing it through the espresso-contrib library. You can scroll to items, click on specific positions, and verify item contents:
package com.example.app.test
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ProductListTest {
@get:Rule
val activityRule = ActivityScenarioRule(ProductListActivity::class.java)
@Test
fun scrollToItemAndClick_navigatesToDetail() {
// Scroll to the 42nd item and click it
onView(withId(R.id.product_recycler_view))
.perform(RecyclerViewActions.scrollToPosition(42))
onView(withId(R.id.product_recycler_view))
.perform(RecyclerViewActions.actionOnItemAtPosition(
42,
click()
))
// Verify detail screen opened
onView(withId(R.id.product_detail_title))
.check(matches(isDisplayed()))
}
@Test
fun checkItemContents_atSpecificPosition() {
// Scroll to position 5 and verify text content
onView(withId(R.id.product_recycler_view))
.perform(RecyclerViewActions.scrollToPosition(5))
// Check the name TextView inside the item at position 5
onView(withId(R.id.product_recycler_view))
.perform(RecyclerViewActions.scrollToHolder(
hasDescendant(withText("Premium Headphones"))
))
// Now verify it's displayed
onView(withText("Premium Headphones"))
.check(matches(isDisplayed()))
}
@Test
fun clickOnChildView_withinRecyclerViewItem() {
// Click a specific button inside the 10th item
onView(withId(R.id.product_recycler_view))
.perform(RecyclerViewActions.actionOnItemAtPosition(
10,
RecyclerViewChildActions.clickOnChild(R.id.add_to_cart_button)
))
// Verify cart badge updated
onView(withId(R.id.cart_badge))
.check(matches(withText("1")))
}
}
Testing Intents and Navigation
Espresso Intents allows you to validate outgoing intents and stub intent responses, enabling you to test navigation flows and external app interactions without actually launching other activities:
package com.example.app.test
import android.app.Instrumentation
import android.content.Intent
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.Intents.intended
import androidx.test.espresso.intent.Intents.intending
import androidx.test.espresso.intent.matcher.IntentMatchers
import androidx.test.espresso.intent.rule.IntentsTestRule
import androidx.test.espresso.matcher.ViewMatchers.withId
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ShareIntentTest {
@get:Rule
val activityRule = ActivityScenarioRule(ShareActivity::class.java)
@Test
fun shareButton_launchesCorrectShareIntent() {
// Initialize Espresso Intents
Intents.init()
// Click the share button
onView(withId(R.id.share_button))
.perform(click())
// Verify the intent was fired with correct action
intended(
IntentMatchers.hasAction(Intent.ACTION_CHOOSER)
)
intended(
IntentMatchers.hasExtra(
Intent.EXTRA_INTENT,
IntentMatchers.hasAction(Intent.ACTION_SEND)
)
)
// Clean up
Intents.release()
}
@Test
fun externalLink_opensCorrectUrl() {
Intents.init()
// Stub the intent to prevent actual browser launch
val expectedUrl = "https://docs.example.com/help"
intending(IntentMatchers.hasAction(Intent.ACTION_VIEW))
.respondWith(
Instrumentation.ActivityResult(
android.app.Activity.RESULT_OK,
null
)
)
// Click the help link
onView(withId(R.id.help_link))
.perform(click())
// Verify the correct URL was attempted
intended(
IntentMatchers.hasData(expectedUrl)
)
Intents.release()
}
}
Handling Asynchronous Operations with IdlingResource
Espresso automatically synchronizes with most UI operations, but long-running background tasks (network calls, database operations, complex animations) can cause tests to proceed before the app is ready. IdlingResource solves this by telling Espresso when the app is busy and when it's idle.
First, create a custom IdlingResource for your background work:
package com.example.app.test
import androidx.test.espresso.IdlingResource
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
/**
* A simple IdlingResource that tracks pending background operations.
* Register this with Espresso before tests that involve async work.
*/
class SimpleIdlingResource : IdlingResource {
private val counter = AtomicInteger(0)
private val resourceCallback = AtomicBoolean(false)
@Volatile
private var callback: IdlingResource.ResourceCallback? = null
override fun getName(): String = "SimpleIdlingResource"
override fun isIdleNow(): Boolean {
val idle = counter.get() == 0
if (idle && resourceCallback.get()) {
callback?.onTransitionToIdle()
resourceCallback.set(false)
}
return idle
}
override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback) {
this.callback = callback
}
/**
* Call this when a background operation starts.
*/
fun increment() {
counter.incrementAndGet()
resourceCallback.set(true)
}
/**
* Call this when a background operation completes.
*/
fun decrement() {
val remaining = counter.decrementAndGet()
if (remaining == 0) {
callback?.onTransitionToIdle()
} else if (remaining < 0) {
throw IllegalStateException("Counter went negative!")
}
}
}
Register the IdlingResource in your application code or expose it via a singleton so tests can access it:
// In your Application or data layer
object EspressoIdlingResource {
@JvmStatic
val countingIdlingResource = SimpleIdlingResource()
fun begin() {
countingIdlingResource.increment()
}
fun end() {
countingIdlingResource.decrement()
}
}
// Usage in your repository or ViewModel
class UserRepository {
suspend fun fetchUserProfile(): User {
EspressoIdlingResource.begin()
try {
// Perform network call
val result = api.getUserProfile()
return result
} finally {
EspressoIdlingResource.end()
}
}
}
In your test, register the IdlingResource before the test and unregister it after:
package com.example.app.test
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.Espresso.registerIdlingResources
import androidx.test.espresso.Espresso.unregisterIdlingResources
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.*
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ProfileLoadTest {
@get:Rule
val activityRule = ActivityScenarioRule(ProfileActivity::class.java)
@Before
fun registerIdlingResource() {
registerIdlingResources(
EspressoIdlingResource.countingIdlingResource
)
}
@After
fun unregisterIdlingResource() {
unregisterIdlingResources(
EspressoIdlingResource.countingIdlingResource
)
}
@Test
fun profileScreen_loadsAndDisplaysUserData() {
// The test will automatically wait for the network call to complete
// No need for Thread.sleep() or manual waits
// Verify the user name is displayed after loading
onView(withId(R.id.profile_name))
.check(matches(withText("John Doe")))
// Verify the email is displayed
onView(withId(R.id.profile_email))
.check(matches(withText("john@example.com")))
}
@Test
fun pullToRefresh_reloadsProfileData() {
// Perform swipe to refresh
onView(withId(R.id.swipe_refresh_layout))
.perform(swipeDown())
// Wait for refresh to complete (IdlingResource handles this)
// Then verify updated data appears
onView(withId(R.id.profile_name))
.check(matches(withText("John Doe")))
}
}
Creating Custom ViewMatchers and ViewActions
When the built-in matchers don't cover your use case, create custom ones. Here's a custom matcher that checks if an ImageView has a specific drawable resource:
package com.example.app.test
import android.widget.ImageView
import androidx.test.espresso.matcher.BoundedMatcher
import org.hamcrest.Description
import org.hamcrest.Matcher
import org.hamcrest.TypeSafeMatcher
object CustomMatchers {
/**
* Matches an ImageView that has a drawable with the given resource ID.
*/
fun hasDrawable(expectedDrawableId: Int): Matcher {
return object : BoundedMatcher(ImageView::class.java) {
override fun describeTo(description: Description) {
description.appendText("has drawable with resource ID: ")
description.appendValue(expectedDrawableId)
}
override fun matchesSafely(imageView: ImageView): Boolean {
val drawable = imageView.drawable
if (drawable == null) {
return false
}
// Compare the constant state which contains resource info
val actualState = drawable.constantState
val expectedDrawable = imageView.context.resources
.getDrawable(expectedDrawableId, null)
val expectedState = expectedDrawable?.constantState
return actualState != null && expectedState != null &&
actualState == expectedState
}
}
}
/**
* Matches a TextView that has specific text color.
*/
fun withTextColor(expectedColor: Int): Matcher {
return object : BoundedMatcher(
android.widget.TextView::class.java
) {
override fun describeTo(description: Description) {
description.appendText("with text color: ")
description.appendValue(expectedColor)
}
override fun matchesSafely(textView: android.widget.TextView): Boolean {
val actualColor = textView.currentTextColor
return actualColor == expectedColor
}
}
}
/**
* Matches a view that has a specific background color.
*/
fun withBackgroundColor(expectedColor: Int): Matcher {
return object : TypeSafeMatcher() {
override fun describeTo(description: Description) {
description.appendText("with background color: ")
description.appendValue(expectedColor)
}
override fun matchesSafely(view: android.view.View): Boolean {
val background = view.background
if (background is android.graphics.drawable.ColorDrawable) {
val actualColor = background.color
return actualColor == expectedColor
}
return false
}
}
}
}
// Usage in tests
@Test
fun profileAvatar_showsCorrectImage() {
onView(withId(R.id.avatar_image))
.check(matches(CustomMatchers.hasDrawable(R.drawable.default_avatar)))
}
@Test
fun errorText_isDisplayedInRed() {
onView(withId(R.id.error_text))
.check(matches(CustomMatchers.withTextColor(
android.graphics.Color.parseColor("#FF0000")
)))
}
You can also create custom ViewActions for specialized interactions:
package com.example.app.test
import android.view.View
import androidx.test.espresso.UiController
import androidx.test.espresso.ViewAction
import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import org.hamcrest.Matcher
import org.hamcrest.Matchers.allOf
object CustomActions {
/**
* Clicks a view at a specific coordinate offset within the view.
*/
fun clickAtPosition(x: Float, y: Float): ViewAction {
return object : ViewAction {
override fun getConstraints(): Matcher {
return allOf(isDisplayed(), isAssignableFrom(View::class.java))
}
override fun getDescription(): String {
return "click at position ($x, $y)"
}
override fun perform(uiController: UiController, view: View) {
val screenCoordinates = IntArray(2)
view.getLocationOnScreen(screenCoordinates)
val screenX = screenCoordinates[0] + x
val screenY = screenCoordinates[1] + y
val motionSpec = android.view.MotionEvent.obtain(
android.os.SystemClock.uptimeMillis(),
android.os.SystemClock.uptimeMillis(),
android.view.MotionEvent.ACTION_DOWN,
screenX,
screenY,
0
)
view.dispatchTouchEvent(motionSpec)
// Wait a bit, then send ACTION_UP
uiController.loopMainThreadForAtLeast(100)
val upSpec = android.view.MotionEvent.obtain(
android.os.SystemClock.uptimeMillis(),
android.os.SystemClock.uptimeMillis(),
android.view.MotionEvent.ACTION_UP,
screenX,
screenY,
0
)
view.dispatchTouchEvent(upSpec)
motionSpec.recycle()
upSpec.recycle()
}
}
}
/**
* Sets a SeekBar to a specific progress value.
*/
fun setSeekBarProgress(progress: Int): ViewAction {
return object : ViewAction {
override fun getConstraints(): Matcher {
return allOf(
isDisplayed(),
isAssignableFrom(android.widget.SeekBar::class.java)
)
}
override fun getDescription(): String {
return "set SeekBar progress to $progress"
}
override fun perform(uiController: UiController, view: View) {
val seekBar = view as android.widget.SeekBar
seekBar.progress = progress
}
}
}
}
// Usage in tests
@Test
fun canvas_tapAtPosition_drawsDot() {
onView(withId(R.id.drawing_canvas))
.perform(CustomActions.clickAtPosition(150f, 300f))
// Verify dot appeared
onView(withId(R.id.dot_indicator))
.check(matches(isDisplayed()))
}
@Test
fun seekBar_setToMiddle_showsCorrectValue() {
onView(withId(R.id.brightness_seekbar))
.perform(CustomActions.setSeekBarProgress(50))
onView(withId(R.id.brightness_value_text))
.check(matches(withText("50%")))
}
Best Practices for Espresso Testing
Keep Tests Focused and Independent
Each test should verify one behavior or user flow. Avoid creating long, sequential tests that depend on prior state. Use the ActivityScenarioRule (or the older ActivityTestRule) to get a fresh activity instance for every test method, ensuring complete isolation.
Use Resource IDs Whenever Possible
Rely on withId(R.id.some_view) as your primary matcher. Resource IDs are stable across refactoring, unlike text content which may change frequently or vary by locale. Reserve text-based matchers for verifying dynamic content.
Create a Test Helper or Page Object Pattern
For complex screens, encapsulate Espresso interactions in a page object or helper class. This keeps tests readable and DRY:
// Page object for LoginScreen
class LoginScreen {
fun typeEmail(email: String): LoginScreen {
onView(withId(R.id.email_input))
.perform(typeText(email))
return this
}
fun typePassword(password: String): LoginScreen {
onView(withId(R.id.password_input))
.perform(typeText(password), closeSoftKeyboard())
return this
}
fun clickLogin(): LoginScreen {
onView(withId(R.id.login_button))
.perform(click())
return this
}
fun verifyErrorShown(errorText: String): LoginScreen {
onView(withId(R.id.error_text))
.check(matches(withText(containsString(errorText))))
return this
}
fun verifyHomeScreenVisible(): LoginScreen {
onView(withId(R.id.home_title))
.check(matches(isDisplayed()))
return this
}
}
// Usage in test
@Test
fun login_withInvalidEmail_showsError() {
LoginScreen()
.typeEmail("invalid_email")
.typePassword("password123")
.clickLogin()
.verifyErrorShown("Invalid email")
}
Use IdlingResource for All Async Work
Never use Thread.sleep() or Thread.wait() in Espresso tests. These create fragile, slow tests. Instead, implement IdlingResource for your background operations (network, database, image loading) and register them with Espresso. This keeps tests fast and eliminates timing flakiness.
Prefer Realistic Test Data
Use production-like data in your tests. If your app displays names, use realistic names like "John Smith" rather than "test" or "asdf." Realistic data catches edge cases (long names, special characters, RTL text) that synthetic data misses.
Run Tests on Multiple Configurations
Execute your Espresso suite on different screen sizes, API levels, and locales. Use connectedAndroidTest with multiple emulators in CI. This catches layout issues, resource mismatches, and localization problems early.
Organize Tests Logically
Group tests by screen or feature in separate test classes. Name test methods clearly using the pattern: feature_condition_expectedBehavior. For example: login_withExpiredToken_redirectsToLoginScreen. This makes test reports immediately understandable.