Skip to content
End To End Tester
Platformspractical

Android App Testing

JUnit and Robolectric for local tests, Espresso and Compose for instrumented UI, and the emulator strategy that keeps an Android suite affordable in CI.

2 min read · updated 19 September 2026

Android splits tests into two directories, and the split matters more than any tooling choice:

  • src/test/ — local JVM tests. Milliseconds. No device.
  • src/androidTest/ — instrumented tests. Seconds. A device or emulator.

Every test you can move from the second to the first is a large win. The emulator is the expensive, slow, flaky part of an Android pipeline.

#Local tests

kotlin
// src/test/java/... — plain JUnit, no Android involved.
class PricingTest {
    @ParameterizedTest
    @CsvSource("12000,2400", "10000,2000", "9999,0")
    fun `discount applies at or above the threshold`(subtotal: Int, expected: Int) {
        val result = Pricing.applyDiscount(Order(subtotal), policy)

        assertThat(result.discountCents).isEqualTo(expected)
    }
}
kotlin
// ViewModel with coroutines — still local, still milliseconds.
class BasketViewModelTest {
    @get:Rule val dispatcher = MainDispatcherRule()   // swaps Dispatchers.Main

    @Test
    fun `shows an error when the basket cannot be loaded`() = runTest {
        val viewModel = BasketViewModel(FakeBasketRepository(failing = true))

        viewModel.load()

        assertThat(viewModel.state.value).isEqualTo(BasketState.Error("could not load"))
    }
}

#Robolectric

For code that needs the Android framework — Context, SharedPreferences, resources, Parcelable — but not a real device:

kotlin
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class SettingsStoreTest {
    @Test
    fun `persists the selected currency`() {
        val context = ApplicationProvider.getApplicationContext<Context>()
        val store = SettingsStore(context)

        store.currency = "GBP"

        assertThat(SettingsStore(context).currency).isEqualTo("GBP")
    }
}

Fifty milliseconds instead of five seconds. It is a simulation of the framework, so it can diverge from a real device — which is why a smaller instrumented suite still has to exist rather than being replaced entirely.

#Espresso

kotlin
@RunWith(AndroidJUnit4::class)
class CheckoutTest {
    @get:Rule val activityRule = ActivityScenarioRule(CheckoutActivity::class.java)

    @Test
    fun paying_shows_a_confirmation() {
        onView(withId(R.id.card_number)).perform(typeText("4242424242424242"), closeSoftKeyboard())
        onView(withId(R.id.pay)).perform(click())

        onView(withText("Order confirmed")).check(matches(isDisplayed()))
    }
}

Espresso's defining feature is that it synchronises with the UI thread: it waits for the message queue to be idle before acting. That removes most of the waiting code other UI frameworks require.

It does not know about your background work. For that, register an idling resource — or better, inject a test dispatcher so there is no background work to wait for:

kotlin
// Preferred: no idling resource needed if the work is synchronous in tests.
@Before fun useTestDispatcher() {
    ServiceLocator.dispatcher = UnconfinedTestDispatcher()
}

#Compose

kotlin
class CheckoutScreenTest {
    @get:Rule val compose = createComposeRule()

    @Test
    fun `pay is disabled until a card is entered`() {
        compose.setContent { CheckoutScreen(state = CheckoutState.Empty) }

        compose.onNodeWithText("Pay").assertIsNotEnabled()

        compose.onNodeWithContentDescription("Card number").performTextInput("4242424242424242")

        compose.onNodeWithText("Pay").assertIsEnabled()
    }

    @Test
    fun `shows a confirmation after a successful payment`() {
        compose.setContent { CheckoutScreen(state = CheckoutState.Paid) }

        compose.onNodeWithTag("confirmation").assertTextEquals("Order confirmed")
    }
}

Compose tests use the semantics tree — the same tree that drives accessibility — which means onNodeWithContentDescription and onNodeWithText are testing what a TalkBack user experiences. The same argument as querying by role on the web.

compose.waitUntil { ... } handles genuinely asynchronous state; the rule otherwise synchronises automatically.

#UI Automator

When the test must leave your app:

kotlin
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())

device.openNotification()
device.wait(Until.hasObject(By.text("Your order has shipped")), 5_000)
device.findObject(By.text("Your order has shipped")).click()

assertThat(device.currentPackageName).isEqualTo("com.example.shop")

Notification shade, share sheet, permission dialogs, another app entirely. Slower and less reliable than Espresso, and the only option for these.

#In CI

yaml
# GitHub Actions
jobs:
  local:
    runs-on: ubuntu-latest        # fast, cheap, runs on every push
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { java-version: '17', distribution: 'temurin' }
      - run: ./gradlew testDebugUnitTest

  instrumented:
    runs-on: ubuntu-latest        # emulator: slower, run it selectively
    steps:
      - uses: actions/checkout@v4
      - uses: gradle/actions/setup-gradle@v4
      - uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 35
          arch: x86_64
          target: google_apis
          # Disabling animations is essential — without it Espresso tests
          # are flaky for reasons that have nothing to do with your code.
          emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim
          script: ./gradlew connectedDebugAndroidTest

Animations are the classic Android flakiness source. Disable them on the device (settings put global window_animation_scale 0 and friends) or via the emulator options; Espresso will otherwise act on a view that is still animating into place.

#Cross-platform

If one team owns both apps, Appium gives a shared suite across Android and iOS at the cost of speed and stability. Setting android:contentDescription on the elements you automate makes that work well, and improves the app's accessibility at the same time.

Common questions

What is the difference between local and instrumented tests on Android?
Local tests run on the JVM on your machine, in milliseconds, with the Android framework either absent or simulated by Robolectric. Instrumented tests run on a device or emulator with the real framework, and take seconds. Put everything you can in the first category.
Espresso or UI Automator?
Espresso for your own app — it synchronises with the UI thread automatically, which is what makes it reliable. UI Automator when the test must cross app boundaries, such as interacting with the notification shade, the system share sheet or another app.
Is Robolectric a good idea?
For anything that needs the Android framework but not a real device, yes — it turns a five-second instrumented test into a fifty-millisecond local one. It is a simulation, so behaviour can diverge from a real device, which means a smaller instrumented suite still has to exist.

Runnable samples for this page

Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.

Was this page useful?