Espresso vs UI Automator: Choosing the Right Android Testing Framework

Espresso vs UI Automator: Choosing the Right Android Testing Framework

Android has two official UI testing frameworks: Espresso and UI Automator. Teams new to Android testing often ask which to use. The answer is usually "both" — they serve different purposes and the distinction matters more than the choice.

The Core Difference

Espresso runs in the same process as your app. It can access app internals, knows about your view hierarchy, and automatically synchronizes with the main thread. It can only test your own app.

UI Automator runs in a separate process. It interacts with the device at the accessibility layer and can test any app on the device, including system apps. It doesn't have access to app internals.

This one difference drives almost every other trade-off.

Architecture

Espresso Architecture

[Test Process] → [Your App Process]
                      │
                 [Espresso]
                      │
                 [View Hierarchy]
                      │
                 [Android UI Thread]

Espresso hooks into the same process as the app. It can:

  • Access View objects directly
  • Read View.getTag(), custom view properties, Adapter data
  • Wait for the UI thread to be idle
  • Access Activity, Fragment, and ViewModel instances

UI Automator Architecture

[Test Process] → [Accessibility Service]
                      │
                 [Any App Process]
                      │
                 [Accessibility Node Tree]

UI Automator communicates through Android's accessibility service. It sees:

  • AccessibilityNodeInfo objects (text, content description, class name)
  • What any app exposes via accessibility APIs
  • Nothing that apps don't expose via accessibility

When to Use Espresso

Use Espresso for your own app's UI tests:

// Espresso test — rich access to view hierarchy
@Test
fun checkout_displaysCorrectTotal() {
    // Access to app's view IDs
    onView(withId(R.id.cartTotal))
        .check(matches(withText("$49.99")))
    
    // Works with custom views
    onView(withId(R.id.priceSlider))
        .check(matches(hasProgress(50)))
    
    // Access RecyclerView items
    onView(withId(R.id.orderSummary))
        .check(matches(atPosition(0, hasDescendant(withText("Widget × 2")))))
}

Espresso is the right choice when:

  • Testing your own app's functionality
  • You need access to view IDs, custom view properties, or adapter data
  • You want automatic synchronization (no explicit waits)
  • Test speed matters (Espresso is faster than UI Automator)

When to Use UI Automator

Use UI Automator for cross-app flows and system interactions:

import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject2
import androidx.test.uiautomator.By
import androidx.test.uiautomator.Until

@RunWith(AndroidJUnit4::class)
class CrossAppTest {

    private lateinit var device: UiDevice

    @Before
    fun setup() {
        device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
    }

    @Test
    fun shareToTwitter_opensTwitterWithCorrectContent() {
        // Start in your app
        val context = ApplicationProvider.getApplicationContext<Context>()
        val intent = context.packageManager.getLaunchIntentForPackage("com.example.myapp")!!
        context.startActivity(intent)
        device.wait(Until.hasObject(By.pkg("com.example.myapp")), 5000)
        
        // Trigger share in your app
        device.findObject(By.res("com.example.myapp", "shareButton")).click()
        
        // Handle the share sheet (system UI)
        device.wait(Until.hasObject(By.text("Twitter")), 3000)
        device.findObject(By.text("Twitter")).click()
        
        // Now we're in Twitter — UI Automator can access it
        device.wait(Until.hasObject(By.pkg("com.twitter.android")), 5000)
        val tweetText = device.findObject(By.clazz("android.widget.EditText"))
        assertNotNull(tweetText)
    }

    @Test
    fun grantPermission_systemDialog_allowed() {
        // Your app requests camera permission
        device.wait(Until.hasObject(By.text("Allow")), 3000)
        device.findObject(By.text("Allow")).click()
        
        // Back in your app
        device.wait(Until.hasObject(By.res("com.example.myapp", "cameraView")), 3000)
        assertNotNull(device.findObject(By.res("com.example.myapp", "cameraView")))
    }
}

UI Automator is the right choice when:

  • Testing flows that leave your app (share sheets, system settings, other apps)
  • Testing permission dialogs
  • Testing system-level behavior (notifications, quick settings)
  • Testing app launch from the home screen or launcher

API Comparison

Finding Views

Espresso:

// Rich options based on View properties
onView(withId(R.id.submitButton))
onView(withText("Submit"))
onView(withContentDescription("close"))
onView(allOf(withId(R.id.label), isDescendantOfA(withId(R.id.card))))
onView(withHint("Enter email"))

UI Automator:

// Limited to what accessibility exposes
device.findObject(By.res("com.example", "submitButton"))
device.findObject(By.text("Submit"))
device.findObject(By.desc("close"))
device.findObject(By.textContains("Submit"))
device.findObjects(By.clazz("android.widget.Button"))
// No By.hint equivalent
// No ancestor/descendant hierarchy access

Performing Actions

Espresso:

onView(withId(R.id.editText)).perform(typeText("Hello"), closeSoftKeyboard())
onView(withId(R.id.slider)).perform(setProgress(75))
onView(withId(R.id.recycler)).perform(RecyclerViewActions.scrollToPosition(10))

UI Automator:

device.findObject(By.res("com.example", "editText")).text = "Hello"
device.findObject(By.res("com.example", "slider")).swipe(Direction.RIGHT, 0.5f)
device.swipe(startX, startY, endX, endY, steps)
device.pressBack()
device.pressHome()
device.openNotification()
device.openQuickSettings()

Waiting for Conditions

Espresso: Automatic — Espresso waits for idle automatically.

UI Automator: Manual — you must specify wait conditions:

// Wait for an object to appear
device.wait(Until.hasObject(By.text("Success")), 5000)

// Wait for an object to disappear
device.wait(Until.gone(By.pkg("com.example.myapp").depth(0)), 5000)

// Find with wait
val button = device.wait(Until.findObject(By.res("com.example", "button")), 3000)
button?.click()

// Wait for condition on a found object
device.findObject(By.res("com.example", "list"))
    .wait(Until.hasObject(By.text("Item 1")), 3000)

The need for explicit waits is UI Automator's biggest ergonomic disadvantage. Without proper waits, tests become timing-dependent and flaky.

Performance

Espresso is significantly faster:

  • Direct in-process communication
  • No accessibility service overhead
  • Automatic synchronization eliminates unnecessary polling delays

A typical Espresso test that clicks 5 elements and checks 3 assertions might take 2–5 seconds.

The equivalent UI Automator test, with wait() calls, might take 10–15 seconds for the same operations — mostly waiting for timeouts on elements that appear immediately.

Test Stability

Espresso tests are more stable due to automatic synchronization. Common flakiness causes in Espresso:

  • Missing IdlingResource for async operations
  • Animations not disabled in test configuration

UI Automator tests are more prone to timing issues. Proper use of Until.hasObject() reduces flakiness, but the test author must remember to add waits everywhere.

Combining Espresso and UI Automator

You can use both in the same test:

@RunWith(AndroidJUnit4::class)
class NotificationTest {

    @get:Rule
    val activityRule = ActivityScenarioRule(MainActivity::class.java)
    
    private lateinit var device: UiDevice

    @Before
    fun setup() {
        device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
    }

    @Test
    fun pushNotification_tapOpensCorrectScreen() {
        // Use Espresso to set up state in your app
        onView(withId(R.id.enableNotificationsToggle)).perform(click())
        
        // Use UI Automator to interact with the notification system
        device.openNotification()
        device.wait(Until.hasObject(By.textContains("New message")), 3000)
        device.findObject(By.textContains("New message")).click()
        
        // Back to Espresso for assertions in your app
        onView(withId(R.id.messageDetail)).check(matches(isDisplayed()))
        onView(withId(R.id.messageContent)).check(matches(withText(containsString("New message"))))
    }
}

This pattern — Espresso for app state setup and assertion, UI Automator for system interactions — is common in comprehensive test suites.

Accessibility and UI Automator

UI Automator is built on the accessibility framework. Testing with UI Automator implicitly tests accessibility: if UI Automator can find an element, a screen reader can find it too. If By.text("Submit") works, TalkBack users can navigate to it.

Use this to your advantage: run UI Automator tests with accessibility services enabled to catch accessibility regressions alongside functional regressions.

Summary

Espresso UI Automator
Scope Your app only Any app on device
Speed Fast Slow (waiting overhead)
Synchronization Automatic Manual waits required
View access Full (IDs, tags, adapters) Accessibility layer only
System UI Limited Full access
Cross-app testing No Yes
Flakiness risk Low (with IdlingResources) Medium (timing-dependent)
Best for App feature testing Cross-app flows, permissions, notifications

Default recommendation: Use Espresso for the vast majority of Android UI tests — it's faster, more stable, and has better access to your app's view hierarchy. Add UI Automator specifically for tests that require system UI interaction, cross-app flows, or permission dialogs. Use them together when a test spans both domains.

Read more

Start now free