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()andcreateAndroidComposeRule(). - Use finders (
onNodeWithText,onNodeWithTag,onNodeWithContentDescription,onAllNodes...), actions (performClick,performTextInput,performScrollTo) and assertions (assertIsDisplayed,assertTextEquals,assertIsEnabled). - Choose between the merged and unmerged tree, and use
printToLogto debug. - Control time and synchronisation with
waitUntil,mainClockand 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.
- 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 insrc/androidTest.
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.
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:
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, ...).
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:
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):
[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
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:
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:
@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.composeTestRule.setContent { ... }: shows only the composable under test. No MainActivity, no navigation, no theme unless you add it. This is isolation.onNodeWithText("Add")is a finder.performClick()is an action.assertExists()is an assertion.- After
performClick, the rule waits for recomposition to finish. There is noThread.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.
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
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.
A form is the standard UI-test target. Our composable:
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.
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.
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.
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?
// 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.
When a finder fails, look at the tree. The Logcat output of printToLog is the Compose test version of a debugger.
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:
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:
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:
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.
}
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.
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:
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:
@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:
android {
testOptions {
animationsDisabled = true
}
}
Test the UI in two layers.
- A stateless composable (
StudentsContent(state, onAdd)), tested by giving it state directly. - A stateful screen (
StudentsScreen(viewModel)), tested with a fake ViewModel or repository.
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:
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:
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()
}
}
You tested a NavHost with TestNavHostController in lesson 26. Here is the structure, in brief, for a UI-level test:
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
/\ 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.
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.
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()
}
}
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.
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.
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.
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.
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
- Jetpack Compose for Android developers, Pathway 4: Testing, performance and accessibility
- Jetpack Compose testing codelab
- Jetpack Compose course page
- Android Basics with Compose course
Project goal: Write a Compose UI test suite for a small shopping cart screen, covering display, interaction, scrolling and state restoration.
Requirements:
- A
CartContent(items, onAdd, onRemove)composable showing a list of items (name, price, quantity), a total, and an empty message. - 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.
- Use
testTagonly for the list; everything else by text or content description. - Use a fake list, no ViewModel, no network.
- Add a
printToLogcall 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.
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") }
}
}
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.
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.
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”.
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.
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.
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