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
// 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)
}
}// 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:
@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
@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:
// Preferred: no idling resource needed if the work is synchronous in tests.
@Before fun useTestDispatcher() {
ServiceLocator.dispatcher = UnconfinedTestDispatcher()
}#Compose
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:
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
# 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 connectedDebugAndroidTestAnimations 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
- reference only
reference/platforms/android-app-testingreference only
Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.
Was this page useful?
Related topics
- iOS App TestingXCTest, Swift Testing, XCUITest and the accessibility identifiers that make UI automation possible — plus what to run on a simulator and what needs a device.
- AppiumOne WebDriver-based API across iOS and Android — how the drivers map onto XCUITest and UIAutomator, locator strategies, and the cost of cross-platform tests.
- Flutter TestingFlutter renders to a canvas with no native accessibility tree, so it brings its own three-layer test harness — unit, widget and integration — plus golden files.
- JUnit 5The Java standard — Jupiter's extension model, parameterised tests, nested classes, assertions with AssertJ, and running it all in CI.
- The Page Object ModelEncapsulating a screen behind a class so tests describe intent rather than selectors — what it fixes, the god-object failure mode, and modern alternatives.
- PlaywrightThe default browser automation tool in 2026 — auto-waiting locators, tracing, sharding and fixtures — with the configuration that matters and the mistakes that still cause flakiness.