Instrumented and UI Testing (Espresso, Compose Testing, UI Automator)

This content was generated with the assistance of AI and should be verified against the official documentation of Android before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

Instrumented tests are the tests that cannot run as plain JVM code because they need a real (or emulated) Android framework underneath them: a Context, a real View hierarchy measured and laid out by the platform, a real SQLiteDatabase, a real accessibility tree, or another app’s UI. They run inside an Instrumentation process on a device or emulator, are slower and less hermetic than the local tests covered on Unit Testing with JUnit, Mockito, MockK and Robolectric, and answer a different question: not "does this class compute the right value" but "does this screen actually do what the user expects when the platform draws it, measures it and dispatches touches to it". This page assumes the fakes/mocks/coroutine-testing vocabulary from that unit-testing page and does not repeat it; it covers AndroidX Test setup, Espresso for the classic View toolkit, Compose UI testing, UI Automator for cross-app flows, testing Hilt- and Room-based code on a real device, and the infrastructure that makes instrumented suites reliable and fast (Test Orchestrator, Gradle Managed Devices, screenshot testing, Firebase Test Lab). Examples build on TrailMate's TrailListScreen (Compose), TrailListFragment (Views, backed by a RecyclerView of Trail items), TrailListViewModel, OfflineFirstTrailRepository, FakeTrailRepository and TrailDao, and target the baseline from Recommended App Architecture (UI, Domain and Data Layers): AndroidX Test core/runner/rules 1.7.0, test.ext:junit 1.3.0, Espresso 3.7.0, UI Automator 2.4.0 and the Compose ui-test-junit4 artifact resolved through the Compose BOM.

Setup: AndroidJUnitRunner and the AndroidX Test Libraries

Instrumented tests live under src/androidTest/kotlin (a separate source set from the src/test/kotlin unit tests) and are compiled into a second APK that Gradle installs alongside the app APK, then drives through Instrumentation. The testInstrumentationRunner in the module’s build.gradle.kts selects which runner performs that orchestration:

// app/build.gradle.kts
android {
    defaultConfig {
        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    }
}

dependencies {
    androidTestImplementation("androidx.test:core:1.7.0")
    androidTestImplementation("androidx.test:runner:1.7.0")
    androidTestImplementation("androidx.test:rules:1.7.0")
    androidTestImplementation("androidx.test.ext:junit:1.3.0")
    androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0")
    androidTestImplementation("androidx.navigation:navigation-testing:2.10.2")
    androidTestImplementation(platform("androidx.compose:compose-bom:2026.09.00"))
    androidTestImplementation("androidx.compose.ui:ui-test-junit4")
    debugImplementation("androidx.compose.ui:ui-test-manifest")
}

AndroidJUnitRunner is the Instrumentation subclass that AndroidX Test ships: it discovers @Test-annotated methods with JUnit 4, installs and instantiates the app under test in the same process as the test APK (so test code can call production classes directly), and reports results back to Gradle/Studio. androidx.test:core supplies ApplicationProvider.getApplicationContext() and ActivityScenario; androidx.test:runner supplies the runner itself; androidx.test:rules supplies JUnit @Rule implementations such as ActivityScenarioRule; and androidx.test.ext:junit supplies AndroidJUnit4 (the @RunWith class instrumented tests use) and ActivityScenarioRule convenience overloads. ui-test-manifest is a debugImplementation-only dependency: it provides an activity that hosts Compose content under test and must never ship in a release build. See Android Developers — Build instrumented tests and Android Developers — Set up Espresso and other AndroidX Test libraries.

ActivityScenario is the AndroidX Test abstraction for driving an activity through lifecycle states from test code, replacing the deprecated ActivityTestRule. ActivityScenarioRule wraps it as a JUnit @Rule that launches the activity before each test and closes it after:

// app/src/androidTest/kotlin/com/example/trailmate/TrailListActivityTest.kt
@RunWith(AndroidJUnit4::class)
class TrailListActivityTest {

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

    @Test
    fun trailList_showsTrailsFromRepository() {
        activityRule.scenario.onActivity { activity ->
            // direct access to the activity instance, off the main thread's Espresso idling
        }
        onView(withText("Ridge Loop")).check(matches(isDisplayed()))
    }
}

scenario.onActivity { } runs a lambda on the main thread with a live reference to the activity — useful for asserting on internal state or triggering a callback directly — while scenario.moveToState(Lifecycle.State.*) drives configuration-change and process-death-like transitions (CREATED, STARTED, RESUMED, DESTROYED) the same way Activities and the Activity Lifecycle describes them happening for real. See Android Developers — Test your activity’s lifecycle interactions and Android Developers — ActivityScenario.

Espresso for the Classic View Toolkit

Espresso is AndroidX Test’s synchronization-aware UI testing framework for View-based screens. Every Espresso statement has the same three-part shape: onView(matcher) finds a view, .perform(action) simulates a user gesture on it, and .check(assertion) verifies its state — and Espresso automatically waits for the main thread and any registered AsyncTask/IdlingResource work to go idle before each step, which is what makes its assertions reliable without manual Thread.sleep.

// app/src/androidTest/kotlin/com/example/trailmate/TrailListFragmentTest.kt
@RunWith(AndroidJUnit4::class)
class TrailListFragmentTest {

    @Test
    fun tappingTrail_navigatesToDetailScreen() {
        val navController = TestNavHostController(ApplicationProvider.getApplicationContext())
        val scenario = launchFragmentInContainer<TrailListFragment>(themeResId = R.style.Theme_TrailMate)
        scenario.onFragment { fragment ->
            navController.setGraph(R.navigation.nav_graph)
            Navigation.setViewNavController(fragment.requireView(), navController)
        }

        onView(withId(R.id.trailRecyclerView))
            .perform(
                RecyclerViewActions.actionOnItem<TrailViewHolder>(
                    hasDescendant(withText("Ridge Loop")),
                    click()
                )
            )

        assertEquals(R.id.trailDetailFragment, navController.currentDestination?.id)
    }
}

withId, withText, hasDescendant and isDisplayed are ViewMatchers; click(), typeText(), scrollTo() and closeSoftKeyboard() are ViewActions; matches() composes a Matcher<View> into a ViewAssertion, and doesNotExist() asserts absence. launchFragmentInContainer (from androidx.fragment:fragment-testing) hosts a single fragment inside an empty test activity, which isolates TrailListFragmentTest from MainActivity — and from its NavHostFragment, so the fragment has no NavController of its own and findNavController() would throw on the click. The test therefore attaches a TestNavHostController (from androidx.navigation:navigation-testing) to the fragment’s view with Navigation.setViewNavController, inside onFragment { } so it runs on the main thread once the view exists, and asserts on the controller’s currentDestination instead of on the detail screen’s UI, which a single-fragment container never shows. See Android Developers — Test navigation, Android Developers — Espresso and Android Developers — Basics.

RecyclerViewActions

Plain ViewActions operate on a matched view directly, but a RecyclerView only inflates the item views currently on screen, so Espresso needs a dedicated adapter-aware action to scroll an off-screen position into view before acting on it. androidx.test.espresso:espresso-contrib supplies RecyclerViewActions:

onView(withId(R.id.trailRecyclerView))
    .perform(RecyclerViewActions.scrollToPosition<TrailViewHolder>(12))

onView(withId(R.id.trailRecyclerView))
    .perform(
        RecyclerViewActions.actionOnItemAtPosition<TrailViewHolder>(0, click())
    )

actionOnItemAtPosition scrolls to a given adapter position and performs the action on its item view; actionOnItem does the same but locates the position with a Matcher<View> (as in hasDescendant(withText(…​)) above), which is more resilient to list reordering than a hard-coded index. See Android Developers — Testing RecyclerView actions.

Espresso-Intents

TrailListFragment starts other activities — opening a map for a trail’s coordinates, or the system share sheet for a TrailPhoto. Espresso-Intents (espresso-intents) intercepts outgoing intents inside the test process so the test can assert on them, or stub a fake result, without ever launching the real target activity:

// app/src/androidTest/kotlin/com/example/trailmate/TrailListFragmentTest.kt
@get:Rule
val intentsRule = IntentsRule()

@Test
fun tappingShare_sendsShareIntentWithTrailName() {
    onView(withId(R.id.shareButton)).perform(click())

    intended(
        allOf(
            hasAction(Intent.ACTION_SEND),
            hasExtra(Intent.EXTRA_TEXT, containsString("Ridge Loop"))
        )
    )
}

@Test
fun choosingPhoto_returnsStubbedImage() {
    val result = Instrumentation.ActivityResult(
        Activity.RESULT_OK,
        Intent().setData(Uri.parse("content://media/external/images/media/42"))
    )
    intending(hasAction(Intent.ACTION_PICK)).respondWith(result)

    onView(withId(R.id.pickPhotoButton)).perform(click())

    onView(withId(R.id.photoPreview)).check(matches(isDisplayed()))
}

IntentsRule initializes and releases Espresso-Intents around each test; intended() asserts that a matching intent was fired; intending().respondWith() stubs the result an external activity would have returned, so TrailListFragmentTest never has to launch the real camera app, maps app or share sheet. See Android Developers — Espresso-Intents.

IdlingResource

Espresso only waits automatically for the main-thread message queue and AsyncTask pool to go idle. Asynchronous work that finishes on a background thread and posts back later — a TrailSyncWorker run, a debounced search, or any callback scheduled with a handler and a delay — is invisible to that default synchronization, and an Espresso assertion that races it will flake. IdlingResource closes that gap: production code (usually only in a test configuration, never shipped as real behavior) reports "busy" while the async work is in flight and "idle" once it completes, and Espresso blocks each onView call until every registered IdlingResource reports idle.

// app/src/androidTest/kotlin/com/example/trailmate/EspressoIdlingResource.kt
object EspressoIdlingResource {
    private val countingResource = CountingIdlingResource("TRAIL_SYNC")

    fun increment() = countingResource.increment()
    fun decrement() = countingResource.decrement()
    val resource: IdlingResource get() = countingResource
}

// app/src/androidTest/kotlin/com/example/trailmate/TrailListFragmentTest.kt
@Before
fun registerIdlingResource() {
    IdlingRegistry.getInstance().register(EspressoIdlingResource.resource)
}

@After
fun unregisterIdlingResource() {
    IdlingRegistry.getInstance().unregister(EspressoIdlingResource.resource)
}

CountingIdlingResource is the ready-made implementation for the common "N operations in flight" case: each increment() call must be matched by exactly one decrement(), or the resource never reports idle again and every later test in the suite hangs until timeout. Coroutine-based code typically avoids IdlingResource altogether by using kotlinx-coroutines-test’s `runTest and a controlled TestDispatcher in unit tests instead (see Unit Testing with JUnit, Mockito, MockK and Robolectric); IdlingResource remains the right tool when the asynchronous work under test genuinely has to run end-to-end, on a real thread, inside an instrumented test. See Android Developers — Idling Resources.

Accessibility Checks

Espresso can run Android’s accessibility scanner against every view it interacts with, failing a test the same way a broken assertion would if a view has no content description, insufficient touch-target size or poor contrast:

@Before
fun enableAccessibilityChecks() {
    AccessibilityChecks.enable().setRunChecksFromRootView(true)
}

Once enabled, every onView(…​).perform(…​) and .check(…​) call also validates the target view (and, with setRunChecksFromRootView(true), the whole view hierarchy) against the same rule set the standalone Accessibility Scanner app uses, catching regressions such as an icon-only button that lost its contentDescription before they reach a real user. This complements, rather than replaces, the semantics-based Compose accessibility testing further down this page and the broader accessibility guidance on Accessibility (Views and Compose). See Android Developers — Accessibility checking with Espresso.

Compose UI Testing

Compose UI tests do not query a View tree; they query the semantics tree, the same tree TalkBack and other accessibility services read, using SemanticsNodeInteraction finders instead of Espresso’s ViewMatchers. The entry point is a ComposeTestRule, obtained from createComposeRule() for a composable tested in isolation, or createAndroidComposeRule<A>() when the test also needs the hosting activity:

// feature/trails/src/androidTest/kotlin/com/example/trailmate/feature/trails/TrailListScreenTest.kt
@RunWith(AndroidJUnit4::class)
class TrailListScreenTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun trailList_rendersTrailsAndHandlesClick() {
        var clickedTrailId: String? = null
        composeTestRule.setContent {
            TrailMateTheme {
                TrailListScreen(
                    uiState = TrailListUiState.Success(
                        trails = listOf(
                            Trail("1", "Ridge Loop", "Sierra Nevada", 8.4, Difficulty.MODERATE, "", false)
                        ),
                        onlyFavorites = false,
                    ),
                    onTrailClick = { clickedTrailId = it }
                )
            }
        }

        composeTestRule.onNodeWithText("Ridge Loop").assertIsDisplayed()
        composeTestRule.onNodeWithText("Ridge Loop").performClick()

        assertEquals("1", clickedTrailId)
    }
}

composeTestRule.setContent { } hosts the composable under test without needing a real activity at all, which is why this test can inject a hand-built TrailListUiState and a plain lambda instead of a full TrailListViewModel — the same test-doubles boundary Unit Testing with JUnit, Mockito, MockK and Robolectric uses for FakeTrailRepository. createAndroidComposeRule<MainActivity>() is the alternative when a test genuinely needs MainActivity’s real intent, permissions or window state alongside `setContent. See Android Developers — Testing your Compose layout and Android Developers — Compose testing APIs.

Finders, Actions and Assertions

Compose testing mirrors Espresso’s three-part shape with semantics-aware equivalents:

Kind Examples Purpose

Finders

onNodeWithText, onNodeWithTag, onNodeWithContentDescription, onAllNodesWithText

Locate one or more `SemanticsNode`s by a semantics property.

Actions

performClick(), performTextInput(), performScrollTo(), performTouchInput { swipeLeft() }

Simulate a gesture or input event on a found node.

Assertions

assertIsDisplayed(), assertTextEquals(), assertIsEnabled(), assertCountEquals(n)

Verify a property of a found node (or set of nodes).

Text-based finders are fragile once a screen supports localization or the same label appears twice, which is why Compose exposes Modifier.testTag("…​") to attach a stable, non-visible identifier a finder can target instead:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListScreen.kt
LazyColumn(modifier = Modifier.testTag("trailList")) {
    items(uiState.trails, key = { it.id }) { trail ->
        TrailRow(
            trail = trail,
            modifier = Modifier.testTag("trailRow_${trail.id}"),
            onClick = { onTrailClick(trail.id) }
        )
    }
}

// feature/trails/src/androidTest/kotlin/com/example/trailmate/feature/trails/TrailListScreenTest.kt
composeTestRule.onNodeWithTag("trailRow_1").performClick()
composeTestRule.onNodeWithTag("trailList").onChildren().assertCountEquals(1)

When a test fails and it is not obvious why a finder matched nothing, composeTestRule.onRoot().printToLog("TAG") dumps the full semantics tree — every node’s tags, text, roles and actions — to Logcat under that tag, which is usually enough to spot a missing testTag or an unexpected merge of sibling nodes into one semantics node. See Android Developers — Compose testing cheat sheet.

Controlling Time: mainClock and waitUntil

By default ComposeTestRule runs its own virtual clock and auto-advances it only far enough to settle recomposition and animations after each action, which keeps most tests deterministic without any explicit waiting. Two escape hatches handle the cases that default behavior does not cover:

// pausing the clock to assert mid-animation
composeTestRule.mainClock.autoAdvance = false
composeTestRule.onNodeWithTag("favoriteIcon").performClick()
composeTestRule.mainClock.advanceTimeBy(150L)
composeTestRule.onNodeWithTag("favoriteIcon").assertIsDisplayed()
composeTestRule.mainClock.autoAdvance = true

// waiting for a real asynchronous condition (network, coroutine, ViewModel state)
composeTestRule.waitUntil(timeoutMillis = 5_000) {
    composeTestRule.onAllNodesWithText("Ridge Loop").fetchSemanticsNodes().isNotEmpty()
}

mainClock.autoAdvance = false plus advanceTimeBy freezes Compose’s animation clock so a test can assert on an intermediate frame of an animation instead of only its settled end state. waitUntil is unrelated to the animation clock: it polls a boolean condition (typically driven by a real TrailListViewModel backed by OfflineFirstTrailRepository, not a test double) until it becomes true or the timeout elapses, which is the correct way to wait for state that arrives from outside Compose’s own recomposition loop — a coroutine, a Room query, a network response. See Android Developers — Compose testing APIs.

StateRestorationTester

Verifying that a composable survives process death — the Compose equivalent of the configuration-change and process-death scenarios on Saving UI State: Views vs. Compose — needs more than ActivityScenario.recreate(), because a composable hosted with createComposeRule() has no real activity to recreate. StateRestorationTester simulates the destroy-and-restore cycle directly against the composition:

@Test
fun favoriteToggle_survivesStateRestoration() {
    val restorationTester = StateRestorationTester(composeTestRule)
    restorationTester.setContent {
        TrailListScreen(uiState = previewUiState, onTrailClick = {})
    }

    composeTestRule.onNodeWithTag("favoriteIcon_1").performClick()
    restorationTester.emulateSavedInstanceStateRestore()

    composeTestRule.onNodeWithTag("favoriteIcon_1").assertIsSelected()
}

emulateSavedInstanceStateRestore() discards the current composition’s remembered state and rebuilds it from a saved Bundle, exactly as a real process restart would, so any state held in rememberSaveable (rather than plain remember) proves it actually survives. See Android Developers — StateRestorationTester.

Hybrid View + Compose Tests

TrailMate’s legacy TrailListFragment hosts some Compose content inside a ComposeView (a common migration step described on Migrating from Views to Compose), and a test for that fragment needs both toolkits' rules together:

// app/src/androidTest/kotlin/com/example/trailmate/TrailListFragmentTest.kt
@get:Rule
val composeTestRule = createAndroidComposeRule<MainActivity>()

@Test
fun composeFooter_updatesAfterViewBasedFilterChip() {
    launchFragmentInContainer<TrailListFragment>()

    onView(withId(R.id.difficultyFilterChip)).perform(click())

    composeTestRule.onNodeWithTag("resultCountFooter").assertTextEquals("3 trails")
}

Espresso’s onView still drives the classic View hierarchy (the filter chip), while composeTestRule.onNodeWith* reaches into the ComposeView’s semantics tree for the composable footer it hosts; Espresso’s automatic idling and Compose’s own idling mechanism cooperate because `ui-test-junit4 registers Compose’s idling with Espresso’s IdlingRegistry automatically when both are on the test classpath. See Android Developers — Migrating Espresso tests to Compose.

UI Automator: Cross-App and System UI Flows

Espresso and Compose testing can only see inside the application process under test. UiAutomator (artifact androidx.test.uiautomator:uiautomator, version 2.4.0) instead drives the whole device through the accessibility service the platform itself exposes, so it can interact with the launcher, the system permission dialog, another app, or a notification shade — anything Espresso structurally cannot reach.

// app/src/androidTest/kotlin/com/example/trailmate/CameraPermissionFlowTest.kt
@RunWith(AndroidJUnit4::class)
class CameraPermissionFlowTest {

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

    @Test
    fun grantingCameraPermission_opensPhotoCaptureScreen() {
        ActivityScenario.launch(MainActivity::class.java)
        device.findObject(By.res("com.example.trailmate", "addPhotoButton")).click()

        val allowButton = device.wait(
            Until.findObject(By.text("While using the app")),
            5_000
        )
        allowButton.click()

        device.findObject(By.res("com.example.trailmate", "photoCaptureRoot")).let {
            assertNotNull(it)
        }
    }
}

UiDevice is the entry point for device-wide gestures (pressHome(), pressBack(), openNotification()) and for locating elements with a BySelector built from By.res(…​), By.text(…​) or By.desc(…​); findObject returns a UiObject2 that supports click(), setText() and swipe(); device.wait(Until.findObject(…​), timeoutMillis) polls until a matching element appears, which is essential for the runtime permission dialog since its exact text and package (com.google.android.permissioncontroller on stock Android) belong to the system UI, not to TrailMate. Because it goes through the accessibility service rather than TrailMate’s own semantics or view tree, UI Automator cannot see inside a ComposeView or View with the same precision as Espresso/Compose testing, so the usual pattern is Espresso or Compose testing for in-app assertions and UI Automator only for the handful of steps — permission dialogs, launcher, another app opened via an intent — that cross a process boundary. See Android Developers — UI Automator.

Testing Hilt-Injected and Room-Backed Code on Device

Hilt Instrumented Tests

An instrumented test that needs TrailMate’s real dependency graph — rather than constructing TrailListViewModel by hand with a FakeTrailRepository — runs inside a Hilt test application and swaps only the bindings it needs to fake, following the same module structure as Dependency Injection with Hilt, Dagger and Koin:

// app/src/androidTest/kotlin/com/example/trailmate/HiltTestRunner.kt
class HiltTestRunner : AndroidJUnitRunner() {
    override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application =
        super.newApplication(cl, HiltTestApplication::class.java.name, context)
}

// app/src/androidTest/kotlin/com/example/trailmate/TrailListEndToEndTest.kt
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class TrailListEndToEndTest {

    @get:Rule(order = 0)
    val hiltRule = HiltAndroidRule(this)

    @get:Rule(order = 1)
    val activityRule = ActivityScenarioRule(MainActivity::class.java)

    @BindValue
    @JvmField
    val trailRepository: TrailRepository = FakeTrailRepository()

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

    @Test
    fun trailList_showsFakeRepositoryData() {
        onView(withText("Ridge Loop")).check(matches(isDisplayed()))
    }
}

testInstrumentationRunner must point at HiltTestRunner (or a subclass generated the same way) instead of the plain AndroidJUnitRunner, because Hilt needs to substitute HiltTestApplication for TrailMate’s own @HiltAndroidApp application class before any component graph is built. HiltAndroidRule must run before ActivityScenarioRule — the order = 0/order = 1 above is what guarantees that — so injection is ready before MainActivity launches; @BindValue swaps a single binding (here, TrailRepository) for the scope of the test class without touching the production RepositoryModule. See Android Developers — Hilt testing guide.

Room DAO Tests on a Real Database

Local (JVM) tests can substitute a fake repository, but TrailDao’s own generated SQL is only meaningful against a real `SQLiteDatabase, which is exactly what an instrumented test provides. Room.inMemoryDatabaseBuilder builds a database that lives only for the test process and is destroyed when it closes:

// core/database/src/androidTest/kotlin/com/example/trailmate/core/database/TrailDaoTest.kt
@RunWith(AndroidJUnit4::class)
class TrailDaoTest {

    private lateinit var database: TrailMateDatabase
    private lateinit var trailDao: TrailDao

    @Before
    fun createDatabase() {
        database = Room.inMemoryDatabaseBuilder(
            ApplicationProvider.getApplicationContext(),
            TrailMateDatabase::class.java
        ).allowMainThreadQueries().build()
        trailDao = database.trailDao()
    }

    @After
    fun closeDatabase() {
        database.close()
    }

    @Test
    fun insertAndReadTrail_returnsSameData() = runTest {
        val entity = TrailEntity(id = "1", name = "Ridge Loop", region = "Sierra Nevada", isFavorite = false)
        trailDao.insert(entity)

        val loaded = trailDao.getById("1").first()

        assertEquals("Ridge Loop", loaded.name)
    }
}

allowMainThreadQueries() keeps this example short; a suite exercising Flow-returning queries at scale usually runs them off the main thread instead and awaits them with kotlinx-coroutines-test’s `runTest, as shown here. An in-memory database still runs the real, generated SQL — foreign keys, indices, type converters and migrations all behave as they would in production — while never touching the file the app itself would use, so TrailDaoTest can run repeatedly without leftover state. See Android Developers — Test your Room database.

Test Orchestrator

By default every instrumented test class in an APK runs inside the same Instrumentation process, so a test that leaks a static field, crashes the process, or leaves shared preferences dirty can corrupt every test that runs after it in the same suite. Android Test Orchestrator changes that by running each test class — optionally each test method, with clearPackageData — in its own Instrumentation invocation:

// app/build.gradle.kts
android {
    defaultConfig {
        testInstrumentationRunnerArguments["clearPackageData"] = "true"
    }
    testOptions {
        execution = "ANDROIDX_TEST_ORCHESTRATOR"
    }
}

dependencies {
    androidTestUtil("androidx.test:orchestrator:1.6.1")
}

With execution = "ANDROIDX_TEST_ORCHESTRATOR", the orchestrator app (installed automatically by Gradle Managed Devices or connectedAndroidTest) takes over scheduling: it lists all tests first, then launches a fresh instrumentation per class, clearing TrailMate’s app data (shared preferences, Room database file, DataStore) in between when clearPackageData is set. The trade-off is per-class startup overhead in exchange for isolation close to what CI needs: a single crashing TrailListEndToEndTest no longer takes the rest of the suite down with it, and no test can pass only because an earlier test happened to leave the database in the right state. See Android Developers — Use Android Test Orchestrator.

Gradle Managed Devices

Instrumented tests still need a device or emulator to run on, and provisioning one by hand (or wiring up a CI emulator image) is exactly the kind of infrastructure work the Android Gradle Plugin can own instead. Gradle Managed Devices declare a virtual device specification directly in Gradle, and the plugin creates, boots, runs the tests on, and tears down that device automatically:

// app/build.gradle.kts
android {
    testOptions {
        managedDevices {
            localDevices {
                create("pixelApi34") {
                    device = "Pixel 8"
                    apiLevel = 34
                    systemImageSource = "aosp-atd"
                }
            }
        }
    }
}

Running ./gradlew pixelApi34DebugAndroidTest provisions that exact device from the Android SDK’s system images, runs the full instrumented suite on it, and reports results the same way connectedAndroidTest would against a manually started emulator — but reproducibly, from source control, without a developer or CI runner needing an emulator already booted. Managed devices also back TrailMate’s own screenshot-testing and Macrobenchmark workflows, since both need a known, consistent device configuration to produce comparable output. See Android Developers — Use Gradle Managed Devices.

Screenshot Testing: Compose Preview Screenshot Testing

Compose Preview Screenshot Testing renders each @Preview composable to a bitmap and diffs it against a previously accepted reference image, catching a visual regression — wrong padding, a broken color, clipped text — that a semantics-based Compose UI test would never notice because the semantics tree can be unchanged while the pixels are wrong.

// feature/trails/build.gradle.kts
plugins {
    id("com.android.compose.screenshot") version "0.0.1-alpha10"
}

// feature/trails/src/screenshotTest/kotlin/com/example/trailmate/feature/trails/TrailListScreenPreviews.kt
@Preview(showBackground = true)
@Composable
private fun TrailListScreenLightPreview() {
    TrailMateTheme {
        TrailListScreen(uiState = previewUiState, onTrailClick = {})
    }
}

Running ./gradlew updateDebugScreenshotTest renders every @Preview in the screenshotTest source set and records its output as the accepted baseline image; ./gradlew validateDebugScreenshotTest re-renders the same previews and fails the build with a pixel diff for any composable whose output changed, without needing a device or emulator at all — rendering happens through Studio’s Layoutlib, the same renderer that shows previews inside the IDE. Because it works from existing @Preview functions, it needs no separate test-writing effort beyond previews TrailMate’s UI code already has for Jetpack Compose Fundamentals. See Android Developers — Compose Preview Screenshot Testing.

Firebase Test Lab

All of the above still runs against one local or Gradle Managed device at a time. Firebase Test Lab runs the same instrumented test APK (built with ./gradlew assembleDebug assembleDebugAndroidTest) against a matrix of real and virtual devices hosted in Google’s infrastructure — different manufacturers, screen sizes, API levels and locales — and is the usual last step before a release candidate ships, catching device-specific issues (a manufacturer’s camera implementation, a tablet layout, an older API level) that a single local emulator cannot. Firebase Test Lab is reached either from the Firebase console, the gcloud firebase test android run CLI, or a Gradle Managed Device configuration pointed at Test Lab instead of a local device; configuring a project on it is covered by CI/CD with GitHub Actions rather than repeated here. See Firebase Documentation — Test your Android app with Android Studio Cloud Testing (Firebase Test Lab).