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 |
|
Locate one or more `SemanticsNode`s by a semantics property. |
Actions |
|
Simulate a gesture or input event on a found node. |
Assertions |
|
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).