RizTech Academy logo
RizTech Academy
Testing, Profiling and DebuggingLesson 3 of 535 min

Testing Compose UI

The unit tests prove the ViewModel decides the right things. They do not prove a farmer sees them: that the Refresh button is really off while a refresh runs, that a tap on the Lasalgaon card opens Lasalgaon, or that TalkBack can read the screen at all. Module 5 promised that because PricesScreen is stateless, a UI test could hand it a filtered list and check the cards without typing anything; module 3 listed testTag as "for the testing module"; module 7 said replacing a Hilt binding for an instrumented test belongs here; and the first-app lesson deleted the androidTest folder and promised it would come back. This lesson pays all four. Mandi Bhav gets its first Compose tests, run both on your computer under Robolectric and on the emulator, and each of the two first attempts fails in a way worth understanding.

Setting it up

The test libraries come from the Compose BOM, so they always match the Compose the app uses. In gradle/libs.versions.toml:

androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-ui-test-junit4-accessibility = { group = "androidx.compose.ui", name = "ui-test-junit4-accessibility" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" }
hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" }

with androidxTestRunner = "1.7.0". In app/build.gradle.kts:

sourceSets {
    // Compose UI tests that need nothing but the screen run both ways:
    // on the JVM under Robolectric (test) and on a device (androidTest).
    getByName("test").kotlin.directories.add("src/sharedTest/kotlin")
    getByName("androidTest").kotlin.directories.add("src/sharedTest/kotlin")
}
testImplementation(platform(libs.androidx.compose.bom))
testImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(libs.androidx.compose.ui.test.junit4.accessibility)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.hilt.android.testing)
kspAndroidTest(libs.hilt.compiler)
// The empty activity createComposeRule() starts; debug builds only.
debugImplementation(libs.androidx.compose.ui.test.manifest)

ui-test-manifest adds an empty activity for a test to put content in. It goes in debugImplementation because a test runs against the debug build, and nothing in it belongs in what you ship. A test file in src/sharedTest/kotlin is compiled twice, once into each test set, so the same test runs on the JVM and on a device. The cost: Android Studio is not always sure which set a shared file belongs to, so expect the odd red squiggle that a build does not agree with.

The first test, and why it fails

The test rule starts an activity and lets the test set its content:

@RunWith(AndroidJUnit4::class)
class PricesScreenTest {

    @get:Rule
    val compose = createAndroidComposeRule<ComponentActivity>()

    private var clickedPriceId: String? = null
    private var refreshes = 0
    private var filtersCleared = 0

    private fun showScreen(state: PricesUiState) {
        compose.setContent {
            MandiBhavTheme {
                PricesScreen(
                    uiState = state,
                    onQueryChange = {},
                    onCommodityClick = {},
                    onClearFilters = { filtersCleared++ },
                    onRefresh = { refreshes++ },
                    onUserMessageShown = {},
                    onPriceClick = { clickedPriceId = it },
                    // A phone-sized window, whatever the test device is.
                    windowSizeClass = WindowSizeClass(minWidthDp = 400, minHeightDp = 800),
                )
            }
        }
    }
    // ...
}

Every on… parameter records what it was asked to do, which is all a stateless screen's test needs: give it a state, act, and check what it reported.

Import createAndroidComposeRule from androidx.compose.ui.test.junit4.v2. The older one, without v2, still works, and the compiler says why not to use it:

w: 'fun <reified A : ComponentActivity> createAndroidComposeRule(effectContext: CoroutineContext = ...): AndroidComposeTestRule<ActivityScenarioRule<A>, A>' is deprecated. Use `androidx.compose.ui.test.junit4.v2.createAndroidComposeRule` instead. The v2 APIs use StandardTestDispatcher instead of UnconfinedTestDispatcher, which aligns with standard coroutine behavior by queuing tasks rather than executing them immediately.

The same distinction as lesson 2's dispatchers: v2 runs effects when the test lets it, not the moment they are launched.

Now the first test anybody writes: find the Lasalgaon card by its market, and tap it.

compose.onNodeWithText("Lasalgaon APMC").performClick()
assertEquals(SamplePrices.lasalgaonOnion.id, clickedPriceId)

It failed, on the JVM and on the emulator alike:

java.lang.AssertionError: Failed to inject touch input.
Reason: Expected exactly '1' node but could not find any node that satisfies:
(Text + InputText + EditableText contains 'Lasalgaon APMC' (ignoreCase: false))
However, the unmerged tree contains '1' node that matches. Are you missing `useUnmergedNode = true` in your finder?

"Lasalgaon APMC" is on the screen; you can see it. But tests do not look at pixels. They look at the semantics tree, the same description of the screen TalkBack reads, and lesson 1 showed what is in it for a price card: one content description, here "Onion at Lasalgaon APMC, 2,450 rupees per quintal…" from the sample prices, and no texts. Module 4's clearAndSetSemantics replaced them, so that TalkBack reads one sentence instead of seven fragments.

The error even offers a way round: useUnmergedNode = true searches the tree before merging, where the Text still exists, and the tap would land on the card. Do not take it. That test would pass while checking something no user of TalkBack can ever reach. Find the card the way TalkBack does:

@Test
fun aCardIsFoundByTheSentenceTalkBackReads() {
    showScreen(everyPrice)

    // The card replaces its texts with one description, so there is no
    // node with the text "Lasalgaon APMC" to find.
    compose.onNodeWithContentDescription(priceCardDescription(SamplePrices.lasalgaonOnion))
        .assertIsDisplayed()
        .assertHasClickAction()
}

priceCardDescription is the same function the card uses, so the test and the screen cannot drift apart. A semantics-based test is also an accessibility test: if you cannot find something by what a screen reader says, neither can a blind farmer.

The second test, and why it passes in one place only

The next test taps the second card, Pune onion:

compose.onNodeWithContentDescription(priceCardDescription(SamplePrices.puneOnion)).performClick()

On the emulator it passed. On the JVM:

java.lang.AssertionError: Failed to inject touch input.
Reason: Expected exactly '1' node but could not find any node that satisfies:
(ContentDescription = 'Onion at Pune (Gultekdi) APMC, 2,200 rupees per quintal. Range 1,000 to 2,800 rupees. 7 November 2026.' (ignoreCase: false))

The windowSizeClass parameter makes the screen lay itself out as one column, but it does not change the size of the window it is drawn in. The emulator's window is a phone, about 411 by 914 dp. Robolectric's default window is 320 by 470 dp, and a LazyVerticalGrid composes only the items that fit: the search field, the chips, the "Updated" line and the first card. Pune onion was not hidden; it did not exist.

Two fixes, both real. Make Robolectric's window phone-sized with @Config(qualifiers = "w411dp-h914dp-xxhdpi") on the test; I ran that and it passed. Or scroll first, which is what a user does and works on any window:

@Test
fun tappingACardOpensThatPrice() {
    showScreen(everyPrice)

    val card = priceCardDescription(SamplePrices.puneOnion)
    // Scroll first: on a small screen, such as Robolectric's default
    // 320 x 470 dp, the second card is not composed until it is needed.
    compose.onNodeWithTag(PRICES_GRID_TAG).performScrollToNode(hasContentDescription(card))
    compose.onNodeWithContentDescription(card).performClick()

    assertEquals(SamplePrices.puneOnion.id, clickedPriceId)
}

performScrollToNode needs the scrolling container, and that is what testTag is for: a name for something with no text and no description to find it by. PricesScreen gives the grid one, Modifier.testTag(PRICES_GRID_TAG), with the constant beside the screen so the test cannot misspell it. Use a test tag for containers and for things a user cannot name; for anything a user can see or hear, find it by its text or description.

The rest of the screen tests

All seven in PricesScreenTest take a state and check what the farmer sees:

Test Gives the screen Checks
aCardIsFoundByTheSentenceTalkBackReads Every price Lasalgaon's card is displayed and clickable
tappingACardOpensThatPrice Every price Scrolling to Pune onion and tapping it reports its id
aFilteredStateShowsOnlyItsCards "pune", Tomato, one price Pune tomato is there, Nashik tomato is not; module 5's promise, with no typing
theLastCardIsThereOnceTheGridScrollsToIt Every price The last card does not exist until the grid scrolls to it
refreshIsOffWhileARefreshRuns isRefreshing = true "Refresh" assertIsNotEnabled()
noMatchesOffersToClearTheFilters A filter with no prices The message, and "Clear filters" in the snackbar calls onClearFilters
aFailedRefreshOffersRetryInTheSnackbar A no-connection message The snackbar's "Retry" calls onRefresh

The snackbar tests show something a unit test cannot: the snackbar is shown by a LaunchedEffect inside the screen, and the test rule waits for the composition and its effects to settle before each finder, so onNodeWithText("Retry") finds it without any sleeping.

The vocabulary, for reference:

Kind Common ones
Finders onNodeWithText, onNodeWithContentDescription, onNodeWithTag, onNode(matcher), onAllNodesWithText
Matchers hasText, hasContentDescription, hasTestTag, hasClickAction, hasSetTextAction, isEnabled, combined with and / or
Actions performClick, performTextInput, performScrollTo, performScrollToNode, performTouchInput { swipeUp() }
Assertions assertIsDisplayed, assertDoesNotExist, assertIsEnabled, assertIsNotEnabled, assertTextEquals, assertCountEquals

assertDoesNotExist and "not displayed" are different: an item scrolled off a lazy list does not exist; a node under another or clipped exists and is not displayed.

The screen and its ViewModel together

PricesRouteTest runs PricesRoute with a real PricesViewModel over lesson 2's fakes, and types:

@Test
fun typingInTheSearchFieldFiltersTheCards() {
    showRoute()

    compose.onNode(hasSetTextAction()).performTextInput("lasalgaon")

    assertEquals("lasalgaon", handle.get<String>("query"))
    compose.onNodeWithContentDescription(priceCardDescription(SamplePrices.lasalgaonOnion)).assertIsDisplayed()
    compose.onNodeWithContentDescription(priceCardDescription(SamplePrices.puneOnion)).assertDoesNotExist()
}

The keystrokes go through the text field, the ViewModel, SavedStateHandle, the filter and back to the grid, all on the JVM. A second test taps Refresh and finds "1 price updated." in the snackbar. These live in app/src/test, not the shared folder, because the fakes they need live there; that is the cost of keeping fakes out of the app.

The whole app on a device, with one binding replaced

An instrumented test can start MainActivity itself, with Hilt building the real graph, Navigation 3, the tabs, the real ViewModels, all of it. What it must not use is the real network or the real database on the phone, which would make it slow, flaky, and destructive of whatever you had saved. Hilt replaces one module for every test:

@Module
@TestInstallIn(components = [SingletonComponent::class], replaces = [PriceRepositoryModule::class])
object TestPriceRepositoryModule {
    @Provides
    @Singleton
    fun providePriceRepository(): PriceRepository = StaticPriceRepository()
}

For that to replace only the repository, the binding had to move out of DataModule into a module of its own, PriceRepositoryModule. @TestInstallIn replaces whole modules, so how you split Hilt modules decides what a test can swap. A small refactor for testability; the app behaves exactly as before.

Hilt also needs its own test application, which a custom runner supplies, named as testInstrumentationRunner = "com.riztechacademy.mandibhav.HiltTestRunner" in defaultConfig:

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

Note what that costs: MandiBhavApplication.onCreate never runs in these tests, so no StrictMode, no notification channel and no daily refresh is scheduled. A test that needs one of those sets it up itself. The test:

@HiltAndroidTest
class MainActivityTest {

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

    @get:Rule(order = 1)
    val compose = createAndroidComposeRule<MainActivity>()

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

    @Test
    fun aCardOpensItsPriceAndBackReturnsToTheList() {
        val card = priceCardDescription(SamplePrices.lasalgaonOnion)
        compose.onNodeWithContentDescription(card).performClick()

        compose.onNodeWithText("Share").assertIsDisplayed()

        compose.activityRule.scenario.onActivity { it.onBackPressedDispatcher.onBackPressed() }
        compose.onNodeWithContentDescription(card).assertIsDisplayed()
    }
}

The Hilt rule must run before the Compose rule starts the activity, hence the order.

Accessibility checks, and where they work

Google's Accessibility Test Framework checks a screen for missing labels, touch targets under 48 dp, low contrast and odd traversal order. Turn it on and run it over the whole screen:

compose.enableAccessibilityChecks()
compose.onRoot().tryPerformAccessibilityChecks()

I first put this in the JVM tests, where it passed. Suspicious of a check that passes first time, I gave it Google's own failing example from the documentation, a clickable grey box with an empty description. Under Robolectric that passed too. The checks do nothing on the JVM. On the emulator the same example failed, as it should:

AccessibilityViewCheckException: There was 1 accessibility result:
View with bounds: [0,0][131,131]: This item may not have a label readable by screen readers.
Reported by com.google.android.apps.common.testing.accessibility.framework.checks.SpeakableTextPresentCheck

and the Prices screen passed. So PricesScreenAccessibilityTest is an instrumented test only. This is lesson 2's rule again, and it is the most important sentence in this lesson: see every test fail once, or you do not know it can.

Getting it onto the device took one more fix. The framework brings Material for Views with it, which wants newer AndroidX libraries than Mandi Bhav ships, and the build refused:

Could not resolve androidx.fragment:fragment:{strictly 1.5.1}.
  Cannot find a version of 'androidx.fragment:fragment' that satisfies the version constraints:
  ... accessibility-test-framework:4.1.1 --> com.google.android.material:material:1.2.0-rc01
      --> androidx.appcompat:appcompat:1.7.1 --> 'androidx.fragment:fragment:1.5.4'
  Constraint path: ... 'androidx.fragment:fragment:{strictly 1.5.1}' because of the following reason:
  version resolved in configuration ':app:debugRuntimeClasspath' by consistent resolution

An instrumented test runs inside the app's own process, so Gradle insists its libraries are the app's. The checks do not need Material, so the test APK leaves it out:

configurations.matching { it.name.startsWith("debugAndroidTest") }.configureEach {
    exclude(group = "com.google.android.material")
}

JVM or device: what each costs

Measured on the course laptop and emulator:

Robolectric (testDebugUnitTest) Emulator (connectedDebugAndroidTest)
The seven shared screen tests 0.48 s, after a few seconds of Robolectric start-up shared with the rest of the run Part of the run below
Everything instrumented (7 shared, 2 Hilt, 1 accessibility) — 14.2 s to run, after about 7 s to build and install two APKs
Needs Nothing An emulator or phone, unlocked, with space for two APKs
Real Robolectric's Android, 320 × 470 dp unless told otherwise; accessibility checks do nothing The real thing, on that device

Run the JVM tests on every change; run the instrumented ones before you merge, and always after changing anything Hilt, navigation or the manifest touches.

Check your work

Why did onNodeWithText("Lasalgaon APMC") find nothing? The card's clearAndSetSemantics replaces its texts with one content description, and finders search the merged semantics tree, not the pixels.

Why not use useUnmergedNode = true? The test would pass by finding a text that TalkBack never reads; finding the card by its description tests what a screen-reader user can reach.

Why did tapping Pune onion pass on the emulator and fail on the JVM? Robolectric's default window is 320 × 470 dp, so the lazy grid had composed only the first card; Pune onion did not exist until the grid scrolled.

Why did the repository binding move into its own module? @TestInstallIn replaces whole modules; with the binding in DataModule, a test would have had to replace every binding in it.

What did Google's failing accessibility example do under Robolectric? It passed, which is how we know the checks do nothing on the JVM; on the emulator it failed with SpeakableTextPresentCheck.

Practice 3. With onPriceClick = {} in place of recording the id, tappingACardOpensThatPrice fails at assertEquals with expected:<pune-onion-2026-11-07> but was:<null>: the tap happened and the screen reported nothing, which is the bug a farmer would see as a card that does nothing.

Practice

  1. Add the libraries, the shared source folder and PricesScreenTest. Run it with ./gradlew testDebugUnitTest and with ./gradlew connectedDebugAndroidTest, and compare the times on your machine with the table.
  2. Write the onNodeWithText("Lasalgaon APMC") test and read its failure. Then open the Layout Inspector on a price card and find the content description the fix uses.
  3. Break it on purpose: in the test's showScreen, replace onPriceClick = { clickedPriceId = it } with onPriceClick = {} and run tappingACardOpensThatPrice. Read the failure, then put it back.
  4. Remove the scroll from tappingACardOpensThatPrice and run it both ways. Then add @Config(qualifiers = "w411dp-h914dp-xxhdpi") and run it on the JVM again.
  5. Add a test that types "vashi" into the search field of PricesRoute, taps the Onion chip, and checks the "No prices match" message. Find the chip by its text.
  6. Harder: write an instrumented accessibility test for the Alerts screen with its form half filled in. If it reports a problem, decide whether it is real, fix it in a scratch copy, and prove the test now passes. Then add the deliberate unlabelled box to see it fail.

Official documentation

Next: profiling jank, memory and battery, from the command line and with Macrobenchmark.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Selected students who finish a course can join our internship, with real-world projects and code review from a working engineer.

About the internship