Skip to main content

Instrumentation Testing

Instrumentation tests run on a real device or emulator. They're slower than unit tests but test the real Android runtime — lifecycle, rendering, real Room, real WorkManager. This chapter covers Compose UI testing, Espresso (for XML), UIAutomator (cross-process), Hilt integration, and snapshot testing.

The instrumented stack

┌─────────────────────────────────────────────────────────────┐
│ AndroidJUnit4 │
│ runs test classes with @RunWith(AndroidJUnit4::class) │
│ │
│ ComposeTestRule │
│ createComposeRule / createAndroidComposeRule │
│ │
│ Espresso │
│ classic XML View assertions and actions │
│ │
│ UIAutomator │
│ cross-app, system UI (notifications, Settings) │
│ │
│ Robolectric (JVM alternative — covered separately) │
└─────────────────────────────────────────────────────────────┘

Setup

// libs.versions.toml
androidx-test-runner = "1.6.2"
androidx-test-rules = "1.6.1"
androidx-test-ext-junit = "1.2.1"
espresso = "3.6.1"
ui-automator = "2.3.0"
compose-bom = "2024.12.01"
hilt = "2.52"
paparazzi = "1.3.5"
roborazzi = "1.30.1"

[libraries]
androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" }
androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test-rules" }
androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext-junit" }
espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" }
espresso-contrib = { module = "androidx.test.espresso:espresso-contrib", version.ref = "espresso" }
espresso-intents = { module = "androidx.test.espresso:espresso-intents", version.ref = "espresso" }
ui-automator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "ui-automator" }
compose-ui-test = { module = "androidx.compose.ui:ui-test-junit4" }
compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" }
android {
defaultConfig {
testInstrumentationRunner = "com.myapp.HiltTestRunner"
}
}

dependencies {
androidTestImplementation(platform(libs.compose.bom))
androidTestImplementation(libs.compose.ui.test)
debugImplementation(libs.compose.ui.test.manifest)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.espresso.core)
androidTestImplementation(libs.hilt.android.testing)
kspAndroidTest(libs.hilt.compiler)
}

Hilt test runner

class HiltTestRunner : AndroidJUnitRunner() {
override fun newApplication(
cl: ClassLoader?, className: String?, context: Context?
): Application = super.newApplication(cl, HiltTestApplication::class.java.name, context)
}

Compose UI testing

Basic Compose test

@RunWith(AndroidJUnit4::class)
class CounterTest {
@get:Rule val compose = createComposeRule()

@Test
fun increments() {
compose.setContent { Counter() }

compose.onNodeWithText("Count: 0").assertIsDisplayed()
compose.onNodeWithText("Increment").performClick()
compose.onNodeWithText("Count: 1").assertIsDisplayed()
}
}

Finders (semantic matchers)

compose.onNodeWithText("Submit")
compose.onNodeWithContentDescription("Close")
compose.onNodeWithTag("submit_btn") // testTag modifier
compose.onAllNodesWithText("Item") // multiple
compose.onNode(hasText("Submit") and hasClickAction())
compose.onNode(hasAnyAncestor(hasTestTag("list")))

Assertions

.assertIsDisplayed()
.assertIsNotDisplayed()
.assertExists() // in tree but maybe off-screen
.assertDoesNotExist()
.assertIsEnabled()
.assertIsFocused()
.assertIsSelected()
.assertIsOn() / .assertIsOff()
.assertTextEquals("Hello")
.assertTextContains("ell")
.assertContentDescriptionEquals("Close")

compose.onAllNodesWithTag("item").assertCountEquals(5)

Actions

.performClick()
.performLongClick()
.performTextInput("hello")
.performTextClearance()
.performScrollTo()
.performScrollToIndex(20)
.performScrollToKey("item-123")
.performImeAction() // Done / Next on keyboard
.performTouchInput { swipeLeft() }
.performKeyPress(KeyEvent(NativeKeyEvent(ACTION_DOWN, KEYCODE_TAB)))

Hilt + Compose test

@HiltAndroidTest
class ProfileScreenTest {

@get:Rule(order = 0) val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1) val compose = createAndroidComposeRule<HiltTestActivity>()

@BindValue @JvmField val userRepo: UserRepository = FakeUserRepository()

@Before
fun setUp() { hiltRule.inject() }

@Test
fun shows_user_name() {
(userRepo as FakeUserRepository).seed(User("u1", "Aarav"))

compose.setContent { AppTheme { ProfileScreen(userId = "u1") } }

compose.onNodeWithText("Aarav").assertIsDisplayed()
}
}

HiltTestActivity is a bare Activity that exists only for testing — your app's MainActivity has its own launch logic we don't want in component tests.

// androidTest/kotlin/.../HiltTestActivity.kt
@AndroidEntryPoint
class HiltTestActivity : ComponentActivity()
<!-- src/debug/AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name="com.myapp.HiltTestActivity"
android:exported="false"/>
</application>
</manifest>
@Test
fun navigates_to_detail_on_click() {
compose.setContent {
val navController = rememberNavController()
AppNavHost(navController)
}

compose.onNodeWithText("Product A").performClick()

compose.waitUntil(5_000) {
compose.onAllNodesWithTag("product_detail").fetchSemanticsNodes().isNotEmpty()
}
compose.onNodeWithTag("product_detail").assertIsDisplayed()
}

Waiting for state

// Wait for a specific condition
compose.waitUntil(timeoutMillis = 5_000) {
compose.onAllNodesWithText("Loaded").fetchSemanticsNodes().isNotEmpty()
}

// Wait for Compose to be idle
compose.waitForIdle()

// Control the main clock for animation testing
compose.mainClock.autoAdvance = false
compose.mainClock.advanceTimeBy(500)
compose.mainClock.advanceTimeByFrame()

Semantics tree dump — debugging

compose.onRoot().printToLog("TREE")

Logcat output:

Node #1 at (0,0)-(1080,2340)px
Node #2: Tag='header'
Text = [Profile]
Node #3: Tag='email_field'
EditableText = [a@x.com]
Focused = true

Great for "why isn't my node found?" — shows the exact tree Compose has.


Espresso — XML View tests

For legacy screens (or during migration), Espresso is the standard:

@RunWith(AndroidJUnit4::class)
class LoginActivityTest {

@get:Rule val activityRule = ActivityScenarioRule(LoginActivity::class.java)

@Test
fun shows_error_on_empty_email() {
onView(withId(R.id.loginBtn)).perform(click())
onView(withId(R.id.emailLayout))
.check(matches(hasErrorText("Email required")))
}

@Test
fun disables_submit_while_loading() {
onView(withId(R.id.email)).perform(typeText("a@x.com"), closeSoftKeyboard())
onView(withId(R.id.password)).perform(typeText("secret123"), closeSoftKeyboard())
onView(withId(R.id.loginBtn)).perform(click())

onView(withId(R.id.loginBtn)).check(matches(not(isEnabled())))
}
}

Intents

@get:Rule val intentsRule = IntentsRule()

@Test
fun opens_browser_on_help_link() {
onView(withId(R.id.helpLink)).perform(click())
intended(allOf(
hasAction(Intent.ACTION_VIEW),
hasData("https://help.example.com")
))
}

RecyclerView

onView(withId(R.id.recyclerView))
.perform(RecyclerViewActions.actionOnItemAtPosition<ViewHolder>(5, click()))

onView(withId(R.id.recyclerView))
.perform(RecyclerViewActions.scrollToPosition<ViewHolder>(50))

Espresso and Compose together

Both can coexist — Espresso actions target AndroidView-hosted views; Compose actions target composables. A hybrid screen works fine:

onView(withId(R.id.toolbar)).check(matches(isDisplayed())) // XML toolbar
compose.onNodeWithText("Continue").performClick() // Compose body

UIAutomator — cross-process / system UI

UIAutomator tests the whole device — system notifications, Settings, other apps. Use it sparingly for end-to-end:

@Test
fun message_notification_appears() {
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())

// Trigger whatever sends a notification
triggerPush()

device.openNotification()
device.wait(Until.hasObject(By.textContains("New message")), 5_000)

val notif = device.findObject(By.textContains("New message"))
assertNotNull(notif)
notif.click()

device.wait(Until.hasObject(By.pkg("com.myapp")), 5_000)
}

Room instrumentation tests

In-memory Room + instrumentation lets you test real SQL without touching a device disk:

@RunWith(AndroidJUnit4::class)
class MessageDaoTest {

private lateinit var db: AppDatabase
private lateinit var dao: MessageDao

@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
.allowMainThreadQueries()
.build()
dao = db.messageDao()
}

@After fun tearDown() { db.close() }

@Test
fun insert_and_observe() = runBlocking {
dao.upsert(sampleMessage)

val observed = dao.observeById("m1").first()
assertEquals("m1", observed?.id)
}

@Test
fun migration_1_to_2() {
val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java.canonicalName
)
helper.createDatabase("test.db", 1).use {
it.execSQL("INSERT INTO messages VALUES (...)")
}
helper.runMigrationsAndValidate("test.db", 2, true, AppDatabase.MIGRATION_1_2)
}
}

Screenshot testing — Paparazzi

Paparazzi renders composables to PNG using layoutlib — deterministic, no emulator required, runs on a dev laptop:

// build.gradle.kts
plugins {
id("app.cash.paparazzi")
}

class PrimaryButtonSnapshotTest {
@get:Rule val paparazzi = Paparazzi(
deviceConfig = DeviceConfig.PIXEL_6,
theme = "android:Theme.Material.Light.NoActionBar"
)

@Test fun light() {
paparazzi.snapshot {
AppTheme { Button(onClick = {}) { Text("Confirm") } }
}
}

@Test fun dark() {
paparazzi.snapshot {
AppTheme(darkTheme = true) { Button(onClick = {}) { Text("Confirm") } }
}
}

@Test fun large_font() {
paparazzi.snapshot(deviceConfig = DeviceConfig.PIXEL_6.copy(fontScale = 2f)) {
AppTheme { Button(onClick = {}) { Text("Confirm") } }
}
}
}
./gradlew recordPaparazziDebug # record baseline PNGs
./gradlew verifyPaparazziDebug # fail on diff

Commit the baselines to git. PR diffs show image changes — a visual code review for design system changes.

Roborazzi — alternative that supports interactions

Roborazzi builds on Robolectric, so you can drive Compose test actions before snapshotting a state:

@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class ExpandableCardSnapshotTest {
@get:Rule val compose = createComposeRule()

@Test
fun expanded_state() {
compose.setContent { ExpandableCard(title = "Title", body = "Body") }

compose.onRoot().captureRoboImage("collapsed.png")

compose.onNodeWithText("Title").performClick()
compose.waitForIdle()

compose.onRoot().captureRoboImage("expanded.png")
}
}

Accessibility testing

@HiltAndroidTest
class ProfileA11yTest {
@get:Rule(order = 0) val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1) val compose = createAndroidComposeRule<HiltTestActivity>()

@Test
fun all_buttons_have_labels() {
compose.setContent { ProfileScreen() }

compose.onAllNodesWithRole(Role.Button)
.assertAll(hasContentDescription() or hasText())
}

@Test
fun a11y_audit_passes() {
compose.setContent { ProfileScreen() }
compose.onRoot().performAccessibilityAudit() // Google A11y Test Framework
}
}

See Module 19 — Enterprise UX for the full accessibility playbook.


Firebase Test Lab — cross-device CI

# CLI — trigger a run across a device matrix
gcloud firebase test android run \
--type instrumentation \
--app app/build/outputs/apk/debug/app-debug.apk \
--test app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk \
--device model=pixel6,version=33,locale=en,orientation=portrait \
--device model=oriole,version=31 \
--device model=blueline,version=28 \
--timeout 10m

Firebase Test Lab runs your instrumented tests on physical devices in Google's lab. Catches device-specific regressions (OEM quirks, API level incompatibilities) you'd never see on your emulator.

Integrate into CI (GitHub Actions, GitLab CI) to run on every release branch.


Emulator vs physical device

EmulatorPhysical device
SpeedFast with x86_64 + KVMSlower, real hardware timing
DeterminismHighOEM variations
SensorsSimulatedReal
CameraVirtual scenesReal
CostFreePhysical lab or Firebase Test Lab
UsePR validation, most testsRelease candidate validation

Use emulators in CI for speed. Run a nightly job on Firebase Test Lab with 3-5 physical device types for coverage.


Test AVD manager

AVDs for CI should use system images with the exact API levels you support:

# Install minimal set
sdkmanager "system-images;android-28;google_apis;x86_64"
sdkmanager "system-images;android-33;google_apis;x86_64"
sdkmanager "system-images;android-34;google_apis;x86_64"

# Create an AVD
echo "no" | avdmanager create avd -n pixel_6_api_34 \
-k "system-images;android-34;google_apis;x86_64" \
-d pixel_6

# Boot with no window
$ANDROID_HOME/emulator/emulator -avd pixel_6_api_34 -no-window -no-audio -no-boot-anim &

Or use the reactivecircus/android-emulator-runner GitHub Action — one line in CI:

- uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 34
target: google_apis
arch: x86_64
script: ./gradlew connectedDebugAndroidTest

Common anti-patterns

Anti-patterns

Test problems

  • Using instrumentation for logic you could unit-test
  • Hardcoded Thread.sleep in instrumented tests
  • Tests depending on network
  • Screenshot tests with timestamps / random IDs
  • testTag on every node (pollutes prod code)
  • Ignoring flaky tests rather than fixing them
Best practices

Reliable instrumentation

  • Unit-test first; instrument for UI integration
  • compose.waitUntil / compose.mainClock APIs
  • Mock network at OkHttp layer; never hit real servers
  • Freeze the clock + seed RNG for deterministic snapshots
  • Prefer semantic finders (onNodeWithText) over testTag
  • Quarantine + fix flakies within a sprint

Key takeaways

Practice exercises

  1. 01

    Hilt + Compose test

    Write a @HiltAndroidTest for a ProfileScreen using createAndroidComposeRule<HiltTestActivity> and @BindValue for a fake repo.

  2. 02

    Navigation flow

    Test a 3-screen flow (home → list → detail) using Compose Test + NavController. Assert back navigation restores list state.

  3. 03

    Paparazzi baseline

    Add Paparazzi for a design-system component. Record Light + Dark + 2x-font + RTL baselines. Break the component intentionally and verify the diff.

  4. 04

    A11y audit

    Call performAccessibilityAudit() on 3 screens. Fix any violations (missing labels, low contrast, small touch targets).

  5. 05

    Firebase Test Lab CI

    Add a CI job that uploads APK + test APK to Firebase Test Lab and runs on 3 device types. Fail the build on any test failure.

Next

Continue to TDD & Code Coverage for the methodology, or Benchmark & Property-Based for performance and advanced testing.