Android UI Testing with Espresso: Getting Started Guide

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 it

ViewMatchers: 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.SmokeTest

For 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.

Read more