Android UI Testing with Espresso: Getting Started Guide
Espresso is Google's recommended framework for Android UI testing. It runs on the device (or emulator) and synchronizes automatically with the UI thread, eliminating most timing issues that plague mobile UI tests. This guide covers setup, core API patterns, handling async operations with IdlingResources, and best practices for writing maintainable Android UI tests.
Key Takeaways
Espresso synchronizes with the UI thread automatically. You don't need Thread.sleep() or explicit wait conditions for UI state. Espresso waits for the UI to be idle before performing each interaction.
ViewMatchers compose. onView(withId(R.id.submit_button).and(isDisplayed())) narrows the matcher to avoid AmbiguousViewMatcherException when the view hierarchy has multiple matches.
IdlingResources handle async operations. Network calls, database operations, and background threads that affect UI state need IdlingResources. Register them before tests, unregister in @After.
Keep tests independent. Each test should set up its own state and not depend on previous tests. Use @Before to navigate to the starting screen consistently.
Use Robot pattern for complex flows. For apps with many screens, the Robot pattern keeps test code readable by hiding Espresso calls behind domain-specific action methods.
Why Espresso Over Generic Test Frameworks
Android UI testing options include Espresso (Google's first-party framework), UIAutomator (cross-app testing), and Appium (cross-platform). Espresso wins for in-app UI testing because:
Automatic synchronization. Espresso knows when the UI thread is idle. After perform(click()), Espresso waits automatically for animations to complete, layout passes to finish, and message queue operations to settle. You write code as if everything is synchronous; Espresso handles the timing.
Access to app internals. Because Espresso runs inside the same process as your app, it can access resources directly (like R.id.button_submit) rather than relying on content descriptions or class names.
No port configuration or driver setup. Espresso requires no external server, no WebDriver, no port forwarding. Just add the dependency and write tests.
Setup
Add Espresso dependencies to your app-level build.gradle:
dependencies {
// Espresso core
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test:rules:1.5.0'
// For RecyclerView testing
androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.5.1'
// For intent stubbing
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.5.1'
// For web view testing
androidTestImplementation 'androidx.test.espresso:espresso-web:3.5.1'
}Set the test runner in build.gradle:
android {
defaultConfig {
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
}Espresso tests live in src/androidTest/java/ (not src/test/java/ which is for unit tests).
Core Concepts: Find, Perform, Check
Every Espresso interaction follows three steps:
onView(ViewMatcher) // 1. Find the view
.perform(ViewAction) // 2. Interact with it
.check(ViewAssertion) // 3. Assert something about itViewMatchers: Finding Views
// By resource ID (most reliable)
onView(withId(R.id.username_input))
// By text content
onView(withText("Submit"))
// By content description (for accessibility)
onView(withContentDescription("Close dialog"))
// By view class
onView(isAssignableFrom(EditText.class))
// Combining matchers (narrows the search)
onView(allOf(
withId(R.id.button),
withText("Continue"),
isDisplayed()
))Use withId() when possible — it's the most stable. View IDs don't change with text localization or content updates. Use withText() when the view has no ID (common in dynamically generated lists).
ViewActions: Interacting with Views
// Typing text
onView(withId(R.id.search_field))
.perform(typeText("espresso testing"), closeSoftKeyboard());
// Clicking
onView(withId(R.id.login_button))
.perform(click());
// Scrolling to and clicking (for views not on screen)
onView(withId(R.id.submit_terms))
.perform(scrollTo(), click());
// Clearing text and retyping
onView(withId(R.id.email_field))
.perform(clearText(), typeText("new@example.com"), closeSoftKeyboard());
// Long click
onView(withId(R.id.item_view))
.perform(longClick());ViewAssertions: Checking State
// View is displayed
onView(withId(R.id.success_message))
.check(matches(isDisplayed()));
// View is not displayed (or doesn't exist)
onView(withId(R.id.error_message))
.check(doesNotExist());
// View has specific text
onView(withId(R.id.welcome_text))
.check(matches(withText("Welcome, Alice!")));
// View is enabled
onView(withId(R.id.submit_button))
.check(matches(isEnabled()));
// View is checked (checkboxes, radio buttons)
onView(withId(R.id.remember_me))
.check(matches(isChecked()));A Complete Test Example
@RunWith(AndroidJUnit4.class)
public class LoginTest {
@Rule
public ActivityScenarioRule<LoginActivity> activityRule =
new ActivityScenarioRule<>(LoginActivity.class);
@Test
public void successfulLoginNavigatesToHome() {
// Enter credentials
onView(withId(R.id.email_input))
.perform(typeText("alice@example.com"), closeSoftKeyboard());
onView(withId(R.id.password_input))
.perform(typeText("securepassword"), closeSoftKeyboard());
// Submit
onView(withId(R.id.login_button))
.perform(click());
// Verify navigation to home screen
onView(withId(R.id.home_fragment_root))
.check(matches(isDisplayed()));
onView(withId(R.id.user_greeting))
.check(matches(withText(containsString("Alice"))));
}
@Test
public void showsErrorOnInvalidCredentials() {
onView(withId(R.id.email_input))
.perform(typeText("wrong@example.com"), closeSoftKeyboard());
onView(withId(R.id.password_input))
.perform(typeText("wrongpassword"), closeSoftKeyboard());
onView(withId(R.id.login_button))
.perform(click());
// Error message appears
onView(withId(R.id.error_text))
.check(matches(isDisplayed()));
onView(withId(R.id.error_text))
.check(matches(withText("Invalid email or password")));
// Still on login screen
onView(withId(R.id.login_button))
.check(matches(isDisplayed()));
}
@Test
public void disablesLoginButtonWhenEmailIsEmpty() {
onView(withId(R.id.password_input))
.perform(typeText("somepassword"), closeSoftKeyboard());
// Login button disabled when email is empty
onView(withId(R.id.login_button))
.check(matches(not(isEnabled())));
}
}RecyclerView Testing
RecyclerViews require the espresso-contrib dependency:
// Scroll to and click the 5th item
onView(withId(R.id.orders_list))
.perform(RecyclerViewActions.actionOnItemAtPosition(4, click()));
// Click an item matching a specific text
onView(withId(R.id.product_list))
.perform(RecyclerViewActions.actionOnItem(
hasDescendant(withText("Blue T-Shirt")),
click()
));
// Assert item count
onView(withId(R.id.search_results))
.check(matches(hasMinimumChildCount(1)));
// Click a view inside a specific item
onView(withId(R.id.cart_items))
.perform(RecyclerViewActions.actionOnItemAtPosition(0,
ViewActions.click() // or a custom ViewAction for a specific child
));Handling Async Operations with IdlingResources
Espresso waits for the UI thread, but not for background operations (network calls, database queries, background threads). If your UI updates after an async operation, you need an IdlingResource.
OkHttp IdlingResource
For apps using OkHttp for networking:
// In your test setup
OkHttpIdlingResource idlingResource;
@Before
public void setUp() {
OkHttpClient client = getOkHttpClientFromYourApp();
idlingResource = OkHttpIdlingResource.create("okhttp", client);
IdlingRegistry.getInstance().register(idlingResource);
}
@After
public void tearDown() {
IdlingRegistry.getInstance().unregister(idlingResource);
}Custom IdlingResource
For custom async operations:
public class DataLoadingIdlingResource implements IdlingResource {
private final ViewModel viewModel;
private ResourceCallback callback;
public DataLoadingIdlingResource(ViewModel viewModel) {
this.viewModel = viewModel;
}
@Override
public boolean isIdleNow() {
boolean idle = !viewModel.isLoading().getValue();
if (idle && callback != null) {
callback.onTransitionToIdle();
}
return idle;
}
@Override
public void registerIdleTransitionCallback(ResourceCallback callback) {
this.callback = callback;
}
@Override
public String getName() {
return "DataLoading";
}
}@Before
public void setUp() {
activityRule.getScenario().onActivity(activity -> {
dataIdling = new DataLoadingIdlingResource(activity.getViewModel());
IdlingRegistry.getInstance().register(dataIdling);
});
}Intent Stubbing for External Activities
When your app launches external activities (camera, file picker, maps), stub the intent to avoid launching real apps during tests:
@RunWith(AndroidJUnit4.class)
public class ProfilePhotoTest {
@Rule
public IntentsTestRule<ProfileActivity> intentsRule =
new IntentsTestRule<>(ProfileActivity.class);
@Test
public void selectsPhotoFromGallery() {
// Stub the intent that launches the gallery
Intent resultData = new Intent();
resultData.setData(Uri.parse("content://test/photo.jpg"));
Instrumentation.ActivityResult result = new Instrumentation.ActivityResult(
Activity.RESULT_OK, resultData
);
intending(hasAction(Intent.ACTION_GET_CONTENT))
.respondWith(result);
// Click the "Add Photo" button — triggers gallery intent
onView(withId(R.id.add_photo_button))
.perform(click());
// Verify the photo was "selected" (intent result processed)
onView(withId(R.id.profile_photo))
.check(matches(isDisplayed()));
}
}The Robot Pattern
For apps with complex flows, raw Espresso code becomes verbose and hard to read. The Robot pattern encapsulates interactions behind readable methods:
// LoginRobot.java
public class LoginRobot {
public LoginRobot enterEmail(String email) {
onView(withId(R.id.email_input))
.perform(typeText(email), closeSoftKeyboard());
return this;
}
public LoginRobot enterPassword(String password) {
onView(withId(R.id.password_input))
.perform(typeText(password), closeSoftKeyboard());
return this;
}
public HomeRobot submit() {
onView(withId(R.id.login_button)).perform(click());
return new HomeRobot();
}
public LoginRobot verifyErrorShown(String message) {
onView(withId(R.id.error_text))
.check(matches(withText(message)));
return this;
}
}
// HomeRobot.java
public class HomeRobot {
public HomeRobot verifyGreeting(String name) {
onView(withId(R.id.greeting))
.check(matches(withText(containsString(name))));
return this;
}
public CartRobot openCart() {
onView(withId(R.id.cart_icon)).perform(click());
return new CartRobot();
}
}Tests become readable:
@Test
public void successfulLoginNavigatesToHome() {
new LoginRobot()
.enterEmail("alice@example.com")
.enterPassword("password123")
.submit()
.verifyGreeting("Alice");
}
@Test
public void showsErrorForInvalidCredentials() {
new LoginRobot()
.enterEmail("wrong@example.com")
.enterPassword("wrongpassword")
.submit()
.verifyErrorShown("Invalid email or password");
}Running Tests
# Run all instrumented tests
./gradlew connectedAndroidTest
<span class="hljs-comment"># Run tests for specific module
./gradlew :app:connectedAndroidTest
<span class="hljs-comment"># Run specific test class
./gradlew connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.example.LoginTest
<span class="hljs-comment"># Run with filter
./gradlew connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.annotation=com.example.SmokeTestFor CI without a physical device, use the Android Emulator or Firebase Test Lab (covered in the Firebase Test Lab guide).
Summary
Espresso is the right choice for Android UI testing within a single app. Its automatic synchronization eliminates the timing issues that make most UI test frameworks painful.
Start with the core pattern (find/perform/check), add IdlingResources for async operations, and use the Robot pattern as your test suite grows. The investment in a well-structured Espresso test suite pays back quickly in caught regressions and developer confidence.
Test on real devices when possible, especially for performance-sensitive interactions and gesture-based UI.