MyFamilyTracker
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
Compose UI testing has a different mental model from View-based testing. Here's how to write reliable, maintainable Compose tests using ComposeTestRule, semantic matchers, and state-driven test patterns.
On this page
Compose changed how Android UIs are built. It also changed how they should be tested. The old Espresso patterns don't map cleanly — Compose has its own testing APIs that align with its declarative model.
Here's how to test Compose UIs effectively.
// build.gradle.kts
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-test-manifest")Every Compose UI test uses
ComposeTestRule@get:Rule
val composeTestRule = createComposeRule()Or for full Activity testing (needed for navigation, theming, hilt):
@get:Rule
val composeTestRule = createAndroidComposeRule<MainActivity>()Compose testing works on semantics — the accessibility tree that describes what your UI contains and can do. You find nodes by semantics, then assert or interact:
// Find → Act → Assert
composeTestRule.onNodeWithText("Save").performClick()
composeTestRule.onNodeWithText("Task saved").assertIsDisplayed()The semantic tree is what TalkBack uses. Writing testable Compose means writing accessible Compose.
// By text content
composeTestRule.onNodeWithText("Submit")
// By content description
composeTestRule.onNodeWithContentDescription("Delete task")
// By test tag (explicit, most stable)
composeTestRule.onNodeWithTag("task_list")
// By role
composeTestRule.onNode(hasClickAction())
composeTestRule.onNode(isToggleable())
// Combined matchers
composeTestRule.onNode(hasText("Submit") and hasClickAction())
// Finding all matching nodes
composeTestRule.onAllNodesWithText("Complete")For component tests, set the content directly:
@Test
fun `empty state shows placeholder message`() {
composeTestRule.setContent {
AppTheme {
TaskListScreen(
tasks = emptyList(),
onAddTask = {}
)
}
}
composeTestRule.onNodeWithText("No tasks yet").assertIsDisplayed()
composeTestRule.onNodeWithText("Add your first task").assertIsDisplayed()
}For ViewModel-driven state:
@Test
fun `completed tasks show with strikethrough style`() {
val viewModel = TaskViewModel(FakeTaskRepository().apply {
addTask(Task("1", "Done task", completed = true))
})
composeTestRule.setContent {
AppTheme {
TaskListScreen(viewModel = viewModel)
}
}
composeTestRule.onNodeWithTag("task_item_1")
.assertIsDisplayed()
composeTestRule.onNodeWithText("Done task")
.assertIsDisplayed()
}// Click
composeTestRule.onNodeWithText("Add Task").performClick()
// Text input
composeTestRule.onNodeWithTag("task_title_input")
.performTextInput("Buy groceries")
// Clear and retype
composeTestRule.onNodeWithTag("task_title_input")
.performTextClearance()
.performTextInput("New text")
// Scroll
composeTestRule.onNodeWithTag("task_list")
.performScrollTo()
// Swipe
composeTestRule.onNodeWithTag("task_item_1")
.performTouchInput { swipeLeft() }// Visibility
composeTestRule.onNodeWithText("Error message").assertIsDisplayed()
composeTestRule.onNodeWithText("Hidden element").assertDoesNotExist()
// Enabled/Disabled
composeTestRule.onNodeWithText("Save").assertIsEnabled()
composeTestRule.onNodeWithText("Save").assertIsNotEnabled()
// Selection state
composeTestRule.onNodeWithTag("checkbox_1").assertIsOn()
composeTestRule.onNodeWithTag("checkbox_2").assertIsOff()
// Check the full semantics (for debugging)
composeTestRule.onNodeWithTag("task_item_1").printToLog("TAG")@Test
fun `user can create and see a new task`() {
composeTestRule.setContent {
AppTheme { TaskApp() }
}
// Initial state: no tasks
composeTestRule.onNodeWithText("No tasks yet").assertIsDisplayed()
// Tap add button
composeTestRule.onNodeWithContentDescription("Add task").performClick()
// Fill in the form
composeTestRule.onNodeWithTag("title_input")
.performTextInput("Buy groceries")
// Save
composeTestRule.onNodeWithText("Save").performClick()
// Task appears in list
composeTestRule.onNodeWithText("Buy groceries").assertIsDisplayed()
// Empty state is gone
composeTestRule.onNodeWithText("No tasks yet").assertDoesNotExist()
}When your UI shows a loading state before content:
@Test
fun `loading state shown then resolves`() {
val repository = SlowFakeRepository() // Delays before returning data
composeTestRule.setContent {
TaskListScreen(viewModel = TaskViewModel(repository))
}
// Loading state
composeTestRule.onNodeWithTag("loading_indicator").assertIsDisplayed()
// Wait for content
composeTestRule.waitUntil(timeoutMillis = 3000) {
composeTestRule.onAllNodesWithTag("task_item").fetchSemanticsNodes().isNotEmpty()
}
// Content loaded
composeTestRule.onNodeWithTag("loading_indicator").assertDoesNotExist()
}Add
testTag// In your composable
LazyColumn(
modifier = Modifier.testTag("task_list")
) {
items(tasks) { task ->
TaskItem(
task = task,
modifier = Modifier.testTag("task_item_${task.id}")
)
}
}[!TIP] Wrap test tag strings in a
object to avoid typos and keep them in sync between the composable and the test:codeTestTagskotlinobject TestTags { const val TASK_LIST = "task_list" fun taskItem(id: String) = "task_item_$id" }
testTagwaitUntilSudarshan Chaudhari
AI Systems Builder / Product Engineer
Bangkok, Thailand
Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.
Related Posts
Related Apps
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
Building something? Available for Android dev and QA consulting.
Work with meComments — powered by Giscus
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
ReadPrivate dream journal — structured entry capture, pattern tagging, and optional Claude-powered insight generation. All data stays on-device by default.
ReadWorkout tracker — exercise logging with set/rep/weight history, goal progression, and local Room DB persistence. No account, no cloud sync required.
Read