Techbase Academy
Android · Lesson 45

Testing Jetpack Compose UIs

15 phasesTest Suite for a Shopping CartAndroid Studio
Phase 1 of 15
Lesson Introduction

You have tested a tip calculator and a ViewModel (lessons 20 and 25). Now we test what the user actually sees and touches: buttons, text fields, lists and screens built with Compose. UI tests are the safety net that lets you change code, upgrade libraries or migrate a screen without wondering "did I break the login button?"

This lesson covers the Write your very first Compose UI test, Testing and How Compose improves UI testing items of Pathway 4 of the Jetpack Compose for Android developers programme (https://developer.android.com/courses/pathways/jetpack-compose-for-android-developers-4), and follows the ideas of the official Testing in Jetpack Compose codelab with our own example app.

By the end you will be able to:

  • Explain the semantics tree and why Compose tests use it instead of Views and ids.
  • Set up the test dependencies and write a test with createComposeRule() and createAndroidComposeRule().
  • Use finders (onNodeWithText, onNodeWithTag, onNodeWithContentDescription, onAllNodes...), actions (performClick, performTextInput, performScrollTo) and assertions (assertIsDisplayed, assertTextEquals, assertIsEnabled).
  • Choose between the merged and unmerged tree, and use printToLog to debug.
  • Control time and synchronisation with waitUntil, mainClock and idling.
  • Test a screen in isolation, with state restoration and a ViewModel fake.
  • Run tests and read the results.

Big idea: a UI test does what a person does: find something on the screen, act on it, and check the result. Compose makes this reliable because the test framework is synchronised with the UI; it waits for recomposition and animations to finish before checking.

✏️ Your Task
Practise what you just learned about Lesson Introduction. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 2 of 15
Prerequisite Concepts
  • Unit tests with JUnit and assertEquals (lesson 20).
  • ViewModel tests (lesson 25).
  • State and state hoisting (lessons 17 and 24).
  • Compose semantics and Modifier (lessons 15–16, 37).
  • Running tests: local tests live in src/test, instrumented tests in src/androidTest.
✏️ Your Task
Practise what you just learned about Prerequisite Concepts. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 3 of 15
Conceptual Understanding: Semantics, Not Views

A View test could say "find the view with id R.id.save_button". Compose has no ids. Instead every composable can describe what it means to the user and to accessibility services. That description is the semantics tree: a parallel tree of nodes, each with properties such as Text, ContentDescription, Role, OnClick, Disabled, TestTag.

text
Composition (what you wrote)           Semantics tree (what tests and TalkBack see)

Row {                                  Node: Row (merged)
  Icon(Icons.Default.Favorite,           ContentDescription = "Favourite"
       contentDescription = "Favourite") Text = "Ada"
  Text("Ada")                            OnClick, Role = Button
}  (clickable)

The same tree is used by accessibility (TalkBack reads it, lessons 46–48) and by tests. So writing accessible UI makes it easier to test, and vice versa. That is one of the ways Compose improves UI testing.

Several more benefits worth naming (the How Compose improves UI testing idea):

Benefit Why it helps
Synchronised The test framework waits until the UI is idle (recomposition and animations finished) before each check, so you rarely need sleeps
Isolated You can render a single composable with setContent, without launching a whole Activity or navigating
Fast No XML inflation, and you can run on the JVM with Robolectric (optional)
Controllable time A test clock lets you jump an animation forward
Same tree as accessibility Tests double as accessibility checks

The three kinds of API

Every Compose UI test is built from three kinds of calls, chained together:

text
composeTestRule.onNodeWithText("Add").performClick().assertIsEnabled()
                \_____ finder _____/ \__ action __/ \__ assertion __/
  • Finders select one node (onNode...) or several (onAllNodes...).
  • Actions simulate input (performClick, performTextInput, ...).
  • Assertions check state (assertIsDisplayed, assertTextEquals, ...).
✏️ Your Task
Practise what you just learned about Conceptual Understanding: Semantics, Not Views. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 4 of 15
Step-by-Step Build 1: Set Up and the Very First Test

Use our small Counter and Greeting examples. Start (or reuse) a Compose project called TestLab, package com.example.testlab.

Dependencies

Compose UI tests are instrumented tests: they run on an emulator or device (or with Robolectric). In app/build.gradle.kts:

build.gradle.kts
android {
 defaultConfig {
 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
 }
}
dependencies {
 // Compose BOM already declared as implementation(platform(...))
 androidTestImplementation(platform(libs.androidx.compose.bom))
 androidTestImplementation(libs.androidx.compose.ui.test.junit4)
 debugImplementation(libs.androidx.compose.ui.test.manifest)
 androidTestImplementation(libs.androidx.junit)
 androidTestImplementation(libs.androidx.espresso.core)
}

And in gradle/libs.versions.toml (versions come from the BOM, so you do not add one for the Compose artifacts):

libs.versions.toml
[libraries]
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }

ui-test-junit4 provides the rule and finders. ui-test-manifest (as a debugImplementation) adds the empty test Activity that createComposeRule() uses to host your composables. New Compose projects from Android Studio already include these lines; if they are missing, tests fail with an error that a ComponentActivity cannot be resolved.

The code to test

Counter.kt
package com.example.testlab
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun Counter(modifier: Modifier = Modifier) {
 var count by rememberSaveable { mutableIntStateOf(0) }
 Column(modifier.padding(16.dp)) {
 Text("Count: $count")
 Button(onClick = { count++ }) { Text("Add") }
 Button(onClick = { count = 0 }, enabled = count > 0) { Text("Reset") }
 }
}

Your first test

In the Project view, the androidTest folder is app/src/androidTest/java/com/example/testlab/. Create CounterTest.kt:

CounterTest.kt
package com.example.testlab
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import org.junit.Rule
import org.junit.Test
class CounterTest {
 @get:Rule
 val composeTestRule = createComposeRule()
 @Test
 fun startsAtZero() {
 composeTestRule.setContent { Counter() }
 composeTestRule.onNodeWithText("Count: 0").assertExists()
 composeTestRule.onNodeWithText("Reset").assertIsNotEnabled()
 }
 @Test
 fun addIncrementsCount() {
 composeTestRule.setContent { Counter() }
 composeTestRule.onNodeWithText("Add").performClick()
 composeTestRule.onNodeWithText("Add").performClick()
 composeTestRule.onNodeWithText("Count: 2").assertExists()
 composeTestRule.onNodeWithText("Reset").assertIsEnabled()
 }
 @Test
 fun resetGoesBackToZero() {
 composeTestRule.setContent { Counter() }
 composeTestRule.onNodeWithText("Add").performClick()
 composeTestRule.onNodeWithText("Reset").performClick()
 composeTestRule.onNodeWithText("Count: 0").assertExists()
 }
}

Walk through it:

  1. @get:Rule val composeTestRule = createComposeRule(): a JUnit rule is code that runs before and after each test. This one starts a test Activity and manages the Compose environment. The @get: prefix is needed because Kotlin properties otherwise apply the annotation to the field, and JUnit needs it on the getter.
  2. composeTestRule.setContent { ... }: shows only the composable under test. No MainActivity, no navigation, no theme unless you add it. This is isolation.
  3. onNodeWithText("Add") is a finder. performClick() is an action. assertExists() is an assertion.
  4. After performClick, the rule waits for recomposition to finish. There is no Thread.sleep.

Run the tests

Right-click CounterTest in the Project view and choose Run 'CounterTest', or click the green triangle next to the class or a single test. Choose an emulator or device. The Run window shows a green check per test. A failure shows the assertion message and the semantics tree at that moment, which is very helpful.

Command line (device needed): ./gradlew connectedDebugAndroidTest.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 1: Set Up and the Very First Test. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 5 of 15
Step-by-Step Build 2: Finders, Actions and Assertions in Depth

Finders

Finder Finds
onNodeWithText("Add") A node whose Text is exactly "Add" (substring with substring = true, ignore case with ignoreCase = true)
onNodeWithContentDescription("Favourite") A node with that content description (icons, images)
onNodeWithTag("studentList") A node with Modifier.testTag("studentList")
onNode(hasText("Add") and hasClickAction()) General matcher with and, or, not
onAllNodesWithText("Delete") Every match, as a collection; use [0], onFirst(), onLast(), assertCountEquals(n)
onRoot() The root of the semantics tree (good for printToLog)
onChild(), onChildren(), onParent(), onSiblings() Navigate the tree from a node

If a finder matches more than one node when you used a single-node finder, the test fails with an error telling you to refine it. That is intentional.

Prefer finders in this order: content description / visible text (what the user sees) first, because they test the real UI and double as accessibility checks; then testTag for things with no stable text (a list container, a dynamic number).

Test tags

kotlin
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
LazyColumn(Modifier.testTag("studentList")) { /* ... */ }

Tags are invisible to users, so use them sparingly and put the tag strings in a shared object Tags so the app and test use the same constant.

Actions

Action Does
performClick() Tap
performTextInput("Ada") Type text into a text field (appends)
performTextClearance() Clear a text field
performTextReplacement("Bola") Replace the text
performScrollTo() Scroll a lazy or scrollable container until the node is visible
performScrollToIndex(20) / performScrollToKey(...) / performScrollToNode(matcher) Scroll a lazy list to a position, key or matching node
performTouchInput { swipeLeft() } Gestures: swipeUp, longClick, doubleClick, pinch ...
performImeAction() Press the keyboard action key
performSemanticsAction(SemanticsActions.OnClick) Call a semantics action directly

Assertions

Assertion Checks
assertExists() / assertDoesNotExist() In/not in the semantics tree
assertIsDisplayed() / assertIsNotDisplayed() Visible on screen (not clipped or scrolled off)
assertIsEnabled() / assertIsNotEnabled() Enabled state
assertIsSelected(), assertIsOn(), assertIsOff() Selection, toggle, checkbox
assertTextEquals("Count: 2") Exact text (can pass several for merged text)
assertTextContains("Ada") Substring
assertContentDescriptionEquals(...) Content description
assertHasClickAction() Is clickable
assertCountEquals(5) On a collection
assert(hasText("x")) Any matcher

Exists vs displayed. A node can exist in the tree yet not be displayed (scrolled off-screen in a Column with verticalScroll, or clipped). In a LazyColumn, off-screen items usually do not exist at all, which is why performScrollTo... matters.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 2: Finders, Actions and Assertions in Depth. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 6 of 15
Step-by-Step Build 3: Testing a Form with Text Input

A form is the standard UI-test target. Our composable:

StudentForm.kt
package com.example.testlab
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun StudentForm(
 onSave: (String) -> Unit,
 modifier: Modifier = Modifier
) {
 var name by rememberSaveable { mutableStateOf("") }
 val error = name.isNotEmpty() && name.length < 2
 Column(modifier.padding(16.dp)) {
 OutlinedTextField(
 value = name,
 onValueChange = { name = it },
 label = { Text("Student name") },
 isError = error,
 supportingText = { if (error) Text("At least 2 letters") }
 )
 Button(onClick = { onSave(name.trim()) }, enabled = name.length >= 2) {
 Text("Save")
 }
 }
}

StudentForm receives its behaviour through onSave: it does not know about a ViewModel or database. That is state hoisting (lesson 24) and it makes the test trivial.

StudentFormTest.kt
package com.example.testlab
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
class StudentFormTest {
 @get:Rule
 val rule = createComposeRule()
 @Test
 fun saveDisabledUntilTwoLetters() {
 rule.setContent { StudentForm(onSave = {}) }
 rule.onNodeWithText("Save").assertIsNotEnabled()
 rule.onNodeWithText("Student name").performTextInput("A")
 rule.onNodeWithText("At least 2 letters").assertExists()
 rule.onNodeWithText("Save").assertIsNotEnabled()
 rule.onNodeWithText("Student name").performTextInput("da")
 rule.onNodeWithText("Save").assertIsEnabled()
 }
 @Test
 fun saveReportsTheTrimmedName() {
 var saved: String? = null
 rule.setContent { StudentForm(onSave = { saved = it }) }
 rule.onNodeWithText("Student name").performTextInput("  Ada ")
 rule.onNodeWithText("Save").performClick()
 assertEquals("Ada", saved)
 }
}

Two points: finding the text field by its label ("Student name") works because the label is part of the field's semantics; and a lambda captured in the test (saved) is the simplest way to check callbacks, a fake collaborator.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 3: Testing a Form with Text Input. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 7 of 15
Step-by-Step Build 4: The Merged and Unmerged Semantics Trees

Many composables merge their children's semantics into one node for accessibility: a clickable Row with an icon and text becomes a single node whose text is the combination. That is exactly what TalkBack announces. Tests use the merged tree by default.

kotlin
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.platform.testTag
@Composable
fun FavouriteRow(name: String, onClick: () -> Unit) {
 Row(Modifier.testTag("row_$name").clickable(onClick = onClick)) {
 Icon(Icons.Filled.Favorite, contentDescription = "Favourite")
 Text(name)
 }
}

Which finders work?

kotlin
// MERGED tree (default): the row is ONE node whose text is "Ada" and description "Favourite"
rule.onNodeWithTag("row_Ada").assertExists()
rule.onNodeWithText("Ada").assertExists() // works: merged text
rule.onNodeWithContentDescription("Favourite").assertExists() // works: merged description
// UNMERGED tree: the Icon and the Text are separate nodes
rule.onNodeWithText("Ada", useUnmergedTree = true).assertExists()
rule.onNodeWithContentDescription("Favourite", useUnmergedTree = true).assertExists()

Use useUnmergedTree = true when you need a specific child (for example to assert on the icon alone) or when a finder cannot find a node because it was merged into its parent. Remember that merged is what assistive technology sees, so prefer testing against it when you can.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 4: The Merged and Unmerged Semantics Trees. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 8 of 15
Step-by-Step Build 5: Debugging Tests with printToLog

When a finder fails, look at the tree. The Logcat output of printToLog is the Compose test version of a debugger.

kotlin
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.printToLog
@Test
fun debugTree() {
 rule.setContent { StudentForm(onSave = {}) }
 rule.onRoot().printToLog("TREE") // merged tree
 rule.onRoot(useUnmergedTree = true).printToLog("RAW") // unmerged tree
}

In Logcat filter by the tag TREE. A sample of what you will see:

text
Printing with useUnmergedTree = 'false'
Node #1 at (l=0.0, t=63.0, r=1080.0, b=2214.0)px
 |-Node #2 at (...)px
 |  Focused = 'false'
 |  EditableText = ''
 |  Text = '[Student name]'
 |  Actions = [OnClick, RequestFocus, SetText, ...]
 |-Node #5 at (...)px
    Role = 'Button'
    Text = '[Save]'
    Disabled = 'kotlin.Unit'

Read it for Text, ContentDescription, TestTag, Role, Disabled and available Actions. The common reasons a finder fails: the text is slightly different (spaces, capitalisation, a string resource), the node is merged into a parent, or the node is not composed yet (lazy list or loading state).

Also useful: rule.onNode(...).assertExists() failures print the whole tree automatically, and fetchSemanticsNode() returns the node for checking custom properties.

Using string resources in tests

To avoid duplicating strings, read them through the test context:

kotlin
import androidx.test.platform.app.InstrumentationRegistry
val context = InstrumentationRegistry.getInstrumentation().targetContext
val saveLabel = context.getString(R.string.save)
rule.onNodeWithText(saveLabel).assertExists()

Or use createAndroidComposeRule<MainActivity>(), then rule.activity.getString(R.string.save). The Android-flavoured rule launches your real Activity, which is what you need for full-app tests with navigation:

kotlin
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import org.junit.Rule
class AppFlowTest {
 @get:Rule
 val rule = createAndroidComposeRule<MainActivity>()
 // rule.activity gives you the Activity; the app's own setContent is used.
}
✏️ Your Task
Practise what you just learned about Step-by-Step Build 5: Debugging Tests with printToLog. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 9 of 15
Step-by-Step Build 6: Lists and Scrolling
StudentListTest.kt
package com.example.testlab
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Text
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performScrollToIndex
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.hasText
import org.junit.Rule
import org.junit.Test
class StudentListTest {
 @get:Rule
 val rule = createComposeRule()
 private val names = List(100) { "Student $it" }
 @Test
 fun lastStudentAppearsAfterScrolling() {
 rule.setContent {
 LazyColumn(Modifier.testTag("list")) {
 items(names) { Text(it) }
 }
 }
 // Student 99 is not composed yet in a lazy list
 rule.onNodeWithTag("list").performScrollToIndex(99)
 rule.onNodeWithText("Student 99").assertIsDisplayed()
 }
 @Test
 fun scrollToNodeByMatcher() {
 rule.setContent {
 LazyColumn(Modifier.testTag("list")) {
 items(names) { Text(it) }
 }
 }
 rule.onNodeWithTag("list").performScrollToNode(hasText("Student 50"))
 rule.onNodeWithText("Student 50").assertIsDisplayed()
 }
}

If a test asserts on an item that is far down a LazyColumn without scrolling first, it fails with "no node found", because lazy lists compose only visible items (lesson 39). performScrollToIndex or performScrollToNode is the answer.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 6: Lists and Scrolling. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 10 of 15
Step-by-Step Build 7: Time, Synchronisation and Animations

The rule auto-synchronises: before each action or assertion it waits for the UI to be idle, including recomposition and animations driven by Compose's clock. Things it cannot know about:

  • Work on other threads such as a network call or database read in a ViewModel that updates state later.
  • Infinite animations, which never reach idle.
  • Delays with real time.

waitUntil

For asynchronous results, wait for a condition:

kotlin
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.onNodeWithText
@OptIn(ExperimentalTestApi::class)
@Test
fun loadedDataAppears() {
 rule.setContent { StudentsScreen(viewModel = FakeViewModel(delayMs = 500)) }
 rule.waitUntilExactlyOneExists(hasText("Ada"), timeoutMillis = 3_000)
 rule.onNodeWithText("Ada").assertExists()
}

waitUntil(timeoutMillis) { condition } is the basic form and returns when the lambda is true or fails on timeout. There are also waitUntilAtLeastOneExists, waitUntilNodeCount and waitUntilDoesNotExist. Some of them are marked experimental (@ExperimentalTestApi) depending on your version. Keep timeouts short: the point is to wait for your own events, not to hide slowness.

A better design: replace the real data source with a fake that returns data immediately, so there is nothing to wait for (the next section).

Controlling the clock

For animations, take control of the test clock:

kotlin
@Test
fun bannerFadesIn() {
 rule.mainClock.autoAdvance = false // stop automatic time
 rule.setContent { WelcomeBanner() }
 rule.mainClock.advanceTimeBy(500) // jump 500 ms (virtual time)
 rule.onNodeWithText("Welcome back!").assertIsDisplayed()
 rule.mainClock.autoAdvance = true
}

The mainClock is virtual: advancing it is instant in real time, so a 5-second animation test takes milliseconds. This is also the cure for tests that hang because of an infinite animation: set autoAdvance = false and step manually (lesson 41).

Disabling animations on the device

Instrumented tests on real emulators are more stable if system animations are off. In Developer options set Window animation scale, Transition animation scale and Animator duration scale to off, or add this to build.gradle.kts so Gradle-managed runs do it for you:

build.gradle.kts
android {
 testOptions {
 animationsDisabled = true
 }
}
✏️ Your Task
Practise what you just learned about Step-by-Step Build 7: Time, Synchronisation and Animations. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 11 of 15
Step-by-Step Build 8: Testing a Screen with a ViewModel and State Restoration

Test the UI in two layers.

  1. A stateless composable (StudentsContent(state, onAdd)), tested by giving it state directly.
  2. A stateful screen (StudentsScreen(viewModel)), tested with a fake ViewModel or repository.
StudentsScreenTest.kt
package com.example.testlab
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.assertIsDisplayed
import org.junit.Rule
import org.junit.Test
class StudentsContentTest {
 @get:Rule
 val rule = createComposeRule()
 @Test
 fun showsEmptyMessage() {
 rule.setContent {
 StudentsContent(state = StudentsUiState(students = emptyList()), onAdd = {})
 }
 rule.onNodeWithText("No students yet").assertIsDisplayed()
 }
 @Test
 fun clickingAddCallsCallback() {
 var added = 0
 rule.setContent {
 StudentsContent(state = StudentsUiState(), onAdd = { added++ })
 }
 rule.onNodeWithText("Add student").performClick()
 kotlin.test.assertEquals(1, added)
 }
}

(kotlin.test needs androidTestImplementation(kotlin("test")); you can use org.junit.Assert.assertEquals instead.)

State restoration

A frequent bug is state that disappears when the Activity is recreated (rotation, process death). Test it with StateRestorationTester:

kotlin
import androidx.compose.ui.test.StateRestorationTester
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
@Test
fun counterSurvivesRecreation() {
 val tester = StateRestorationTester(rule)
 tester.setContent { Counter() }
 rule.onNodeWithText("Add").performClick()
 rule.onNodeWithText("Count: 1").assertExists()
 tester.emulateSavedInstanceStateRestore()
 rule.onNodeWithText("Count: 1").assertExists() // passes only with rememberSaveable
}

Change rememberSaveable to remember in Counter and this test fails. That is how you catch the lesson 23 bug automatically.

Theme, locale and font scale in tests

Wrap content in your theme to test with it: rule.setContent { MyAppTheme { Counter() } }. To check large fonts, provide a LocalDensity with a bigger fontScale:

kotlin
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
rule.setContent {
 val d = LocalDensity.current
 CompositionLocalProvider(LocalDensity provides Density(d.density, fontScale = 2f)) {
 Counter()
 }
}
✏️ Your Task
Practise what you just learned about Step-by-Step Build 8: Testing a Screen with a ViewModel and State Restoration. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 12 of 15
Step-by-Step Build 9: Testing Navigation and Full Flows

You tested a NavHost with TestNavHostController in lesson 26. Here is the structure, in brief, for a UI-level test:

kotlin
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.navigation.compose.ComposeNavigator
import androidx.navigation.testing.TestNavHostController
import androidx.compose.ui.platform.LocalContext
import org.junit.Assert.assertEquals
@Test
fun tappingStudentOpensDetail() {
 lateinit var navController: TestNavHostController
 rule.setContent {
 navController = TestNavHostController(LocalContext.current).apply {
 navigatorProvider.addNavigator(ComposeNavigator())
 }
 AppNavHost(navController = navController)
 }
 rule.onNodeWithText("Ada").performClick()
 assertEquals("detail/{id}", navController.currentBackStackEntry?.destination?.route)
}

Your AppNavHost must accept the controller as a parameter for this to work, another reason to keep controllers out of deep code. The dependency is androidx.navigation:navigation-testing as androidTestImplementation. (With type-safe routes, check the destination with hasRoute<Detail>() from androidx.navigation.) A test against createAndroidComposeRule<MainActivity>() covers an entire flow, but such tests are slower and more brittle; keep most tests small and a few for the critical journeys (sign-in, add item, checkout).

The testing pyramid

text
         /\        few    Full-app UI flow tests (slow, realistic)
        /  \
       /----\      some   Screen tests with fakes (Compose rule, isolated)
      /      \
     /--------\    many   Unit tests: ViewModels, repositories, logic (fast, JVM)

A good project has many cheap unit tests at the bottom, a moderate number of Compose screen tests, and a handful of end-to-end tests.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 9: Testing Navigation and Full Flows. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 13 of 15
Guided Practice Exercises
🎯 Your Challenge

Exercise 1: Test the Reset button

Using the Counter composable, write a test that checks: Reset is disabled at 0; after one tap on Add it is enabled; after tapping Reset the text goes back to "Count: 0" and Reset is disabled again.

kotlin
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import org.junit.Rule
import org.junit.Test
class ResetTest {
 @get:Rule
 val rule = createComposeRule()
 @Test
 fun resetLifecycle() {
 rule.setContent { Counter() }
 rule.onNodeWithText("Reset").assertIsNotEnabled()
 rule.onNodeWithText("Add").performClick()
 rule.onNodeWithText("Reset").assertIsEnabled()
 rule.onNodeWithText("Reset").performClick()
 rule.onNodeWithText("Count: 0").assertExists()
 rule.onNodeWithText("Reset").assertIsNotEnabled()
 }
}
🎯 Your Challenge

Exercise 2: Find an element in the unmerged tree

A ProductCard is Card(Modifier.clickable { }) { Image(contentDescription = "Product photo"); Text("Zobo ₦250") }. A test onNodeWithContentDescription("Product photo").assertExists() fails. Explain why, and give two fixes.

clickable merges descendants into a single semantics node, so the image's description is merged into the card node and the Image is not a separate node in the merged tree. Fix 1: query the unmerged tree, onNodeWithContentDescription("Product photo", useUnmergedTree = true). Fix 2: assert against the merged node, for example onNode(hasContentDescription("Product photo") and hasClickAction()), or add a testTag to the card and check assertContentDescriptionEquals. Use printToLog to see which is true.

🎯 Your Challenge

Exercise 3: Prove state survives rotation

Write a test for the StudentForm that types "Ada", simulates recreation, and asserts "Ada" is still in the field. Say what happens if rememberSaveable is replaced with remember.

kotlin
import androidx.compose.ui.test.StateRestorationTester
import androidx.compose.ui.test.assertTextContains
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performTextInput
import org.junit.Rule
import org.junit.Test
class FormRestoreTest {
 @get:Rule
 val rule = createComposeRule()
 @Test
 fun nameSurvivesRecreation() {
 val tester = StateRestorationTester(rule)
 tester.setContent { StudentForm(onSave = {}) }
 rule.onNodeWithText("Student name").performTextInput("Ada")
 tester.emulateSavedInstanceStateRestore()
 rule.onNodeWithText("Student name").assertTextContains("Ada")
 }
}

With plain remember the field resets to empty after restoration, so the assertion fails, which reveals the bug.

✏️ Your Task
Practise what you just learned about Guided Practice Exercises. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 14 of 15
Common Mistakes (and How to Fix Them)

Mistake #1: Putting UI tests in src/test. Symptom: unresolved references to the rule or "Method not mocked". Fix: src/androidTest for instrumented tests (or configure Robolectric for JVM tests).

Mistake #2: Missing ui-test-manifest. Symptom: "Unable to resolve activity for Intent". Fix: add it as debugImplementation.

Mistake #3: Thread.sleep for async work. Symptom: slow, flaky tests. Fix: waitUntil, fakes and the test clock.

Mistake #4: Finding by text that appears several times. Symptom: "Expected exactly 1 node but found 2". Fix: refine with matchers or onAllNodes...[index].

Mistake #5: Testing implementation, not behaviour. Symptom: tests break on refactors. Fix: assert what the user sees.

Mistake #6: Forgetting @get:Rule. Symptom: "rule must be public" or the rule never runs. Fix: @get:Rule val rule = ....

Mistake #7: Asserting on off-screen lazy items. Symptom: "no node found". Fix: performScrollToIndex / performScrollToNode.

Mistake #8: Tests that depend on the real network. Symptom: failures with no code change. Fix: inject fakes.

Mistake #9: Infinite animations hang waitForIdle. Fix: mainClock.autoAdvance = false, or disable the animation in tests.

Mistake #10: Overusing test tags. Symptom: tests unrelated to accessibility. Fix: prefer text and content descriptions where the user would use them.

✏️ Your Task
Practise what you just learned about Common Mistakes (and How to Fix Them). Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 15 of 15
Knowledge Check

Question 1. What is the semantics tree and why does Compose testing use it?

It is a tree describing what each part of the UI means (text, description, role, actions, state). It is shared by accessibility services and tests, so tests find and check UI by meaning instead of by view ids.

Question 2. Name the three kinds of calls in a Compose UI test.

Finders (select nodes), actions (simulate input such as performClick) and assertions (check state such as assertIsDisplayed).

Question 3. When would you use useUnmergedTree = true?

When a child node has been merged into its parent's semantics and you need to find or check the child by itself, for example an icon inside a clickable row.

Question 4. How do you test a screen that loads data asynchronously?

Prefer a fake data source that returns instantly. When you must wait, use waitUntil (or waitUntilExactlyOneExists) with a short timeout. For animations, control mainClock.

Question 5. How does StateRestorationTester help?

It lets a test simulate saving and restoring instance state (as on rotation or process death), so you can check that rememberSaveable state survives and catch state that would be lost.

Go deeper: official resources

✏️ Your Task
Practise what you just learned about Knowledge Check. Open Android Studio, type the examples by hand, change a value, and observe what happens.
🏗️ Build It — Mini Project
Test Suite for a Shopping Cart

Project goal: Write a Compose UI test suite for a small shopping cart screen, covering display, interaction, scrolling and state restoration.

Requirements:

  1. A CartContent(items, onAdd, onRemove) composable showing a list of items (name, price, quantity), a total, and an empty message.
  2. At least eight tests: empty state, total calculation, add, remove, disabled minus at quantity 1, scroll to the last item in a list of 50, content descriptions on buttons, and state restoration of a "coupon code" field.
  3. Use testTag only for the list; everything else by text or content description.
  4. Use a fake list, no ViewModel, no network.
  5. Add a printToLog call to one test and read the output.

Stretch goal: add StateRestorationTester for the quantity and a test with fontScale = 2f that checks the Checkout button is still displayed.

Cart.kt
package com.example.testlab
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
data class CartItem(val id: Int, val name: String, val price: Int, val qty: Int)
@Composable
fun CartContent(
 items: List<CartItem>,
 onAdd: (Int) -> Unit,
 onRemove: (Int) -> Unit,
 modifier: Modifier = Modifier
) {
 var coupon by rememberSaveable { mutableStateOf("") }
 Column(modifier.padding(16.dp)) {
 if (items.isEmpty()) {
 Text("Your cart is empty")
 } else {
 LazyColumn(Modifier.weight(1f).testTag("cartList")) {
 items(items, key = { it.id }) { item ->
 Row(
 Modifier.fillMaxWidth().padding(vertical = 4.dp),
 horizontalArrangement = Arrangement.SpaceBetween,
 verticalAlignment = Alignment.CenterVertically
 ) {
 Text("${item.name} ₦${item.price}")
 Row(verticalAlignment = Alignment.CenterVertically) {
 Button(
 onClick = { onRemove(item.id) },
 enabled = item.qty > 1,
 modifier = Modifier.semantics { contentDescription = "Remove one ${item.name}" }
 ) { Text("-") }
 Text("${item.qty}", Modifier.padding(horizontal = 8.dp))
 Button(
 onClick = { onAdd(item.id) },
 modifier = Modifier.semantics { contentDescription = "Add one ${item.name}" }
 ) { Text("+") }
 }
 }
 }
 }
 val total = items.sumOf { it.price * it.qty }
 Text("Total: ₦$total")
 }
 OutlinedTextField(value = coupon, onValueChange = { coupon = it }, label = { Text("Coupon code") })
 Button(onClick = {}) { Text("Checkout") }
 }
}
CartTest.kt
package com.example.testlab
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.StateRestorationTester
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertTextContains
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToIndex
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.printToLog
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
class CartTest {
 @get:Rule
 val rule = createComposeRule()
 private val one = listOf(CartItem(1, "Zobo", 250, 1))
 @Test
 fun emptyCartShowsMessage() {
 rule.setContent { CartContent(emptyList(), {}, {}) }
 rule.onNodeWithText("Your cart is empty").assertIsDisplayed()
 }
 @Test
 fun totalIsPriceTimesQuantity() {
 rule.setContent {
 CartContent(listOf(CartItem(1, "Zobo", 250, 2), CartItem(2, "Akara", 400, 1)), {}, {})
 }
 rule.onNodeWithText("Total: ₦900").assertIsDisplayed()
 }
 @Test
 fun addButtonReportsItemId() {
 var added = -1
 rule.setContent { CartContent(one, onAdd = { added = it }, onRemove = {}) }
 rule.onNodeWithContentDescription("Add one Zobo").performClick()
 assertEquals(1, added)
 }
 @Test
 fun removeDisabledAtQuantityOne() {
 rule.setContent { CartContent(one, {}, {}) }
 rule.onNodeWithContentDescription("Remove one Zobo").assertIsNotEnabled()
 }
 @Test
 fun lastItemReachableByScrolling() {
 val many = List(50) { CartItem(it, "Item $it", 100, 1) }
 rule.setContent { CartContent(many, {}, {}) }
 rule.onNodeWithTag("cartList").performScrollToIndex(49)
 rule.onNodeWithText("Item 49 ₦100").assertIsDisplayed()
 }
 @Test
 fun couponSurvivesRecreation() {
 val tester = StateRestorationTester(rule)
 tester.setContent { CartContent(one, {}, {}) }
 rule.onNodeWithText("Coupon code").performTextInput("NAIJA10")
 tester.emulateSavedInstanceStateRestore()
 rule.onNodeWithText("Coupon code").assertTextContains("NAIJA10")
 }
 @Test
 fun checkoutButtonExists() {
 rule.setContent { CartContent(one, {}, {}) }
 rule.onNodeWithText("Checkout").assertIsDisplayed()
 }
 @Test
 fun debugPrint() {
 rule.setContent { CartContent(one, {}, {}) }
 rule.onRoot().printToLog("CART")
 }
}

Expected result: all eight tests pass on an emulator. Because the item rows contain a Text with "Zobo ₦250" and buttons with their own descriptions, the finders match one node each. If onNodeWithText("Item 49 ₦100") fails, run printToLog to see how the text appears (watch for the ₦ character and spacing), then fix the finder.

1

Open your finished project in Android Studio. Check that the .gitignore file the wizard created is in the project root. It keeps local.properties, build folders and keystores out of Git. Never commit passwords or signing keys.

2

Choose Git > Create Git Repository… (or VCS > Enable Version Control Integration in older versions), select the project folder, then open Git > Commit, tick every file, and write the message “Add Android lesson 45 project”.

3

On github.com click New repository, name it android-lesson-45-testing-jetpack-compose-uis, set it to Public, leave Add a README unticked, and click Create repository.

4

Back in Android Studio choose Git > Manage Remotes…, add the repository URL as origin, then choose Git > Push… and push your main branch. Prefer the terminal? Run git remote add origin <your-url> then git push -u origin main.

5

Add a README.md with a screenshot of your app running and a short description. Employers and clients read READMEs first. Your project is now part of your public portfolio. 🎉

Lesson 45 complete! 🎉

You covered:

  • Lesson Introduction
  • Prerequisite Concepts
  • Conceptual Understanding: Semantics, Not Views
  • Step-by-Step Build 1: Set Up and the Very First Test
  • Step-by-Step Build 2: Finders, Actions and Assertions in Depth
  • Step-by-Step Build 3: Testing a Form with Text Input
  • Step-by-Step Build 4: The Merged and Unmerged Semantics Trees
  • Step-by-Step Build 5: Debugging Tests with printToLog
  • Step-by-Step Build 6: Lists and Scrolling
  • Step-by-Step Build 7: Time, Synchronisation and Animations
  • Step-by-Step Build 8: Testing a Screen with a ViewModel and State Restoration
  • Step-by-Step Build 9: Testing Navigation and Full Flows
  • Guided Practice Exercises
  • Common Mistakes (and How to Fix Them)
  • Knowledge Check
All 72 lessons in Google Android Studio ·Techbase Academy ·© 2025–2026 Techbase Consultant Services, Ibadan
Esc
↑ ↓ to moveEnter to openEsc to close