Skip to content
All posts
March 13, 20263 min read

Testing Jetpack Compose UI: A Practical Guide with Real Examples

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.

AndroidJetpack ComposeTestingKotlin
Share:

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.


Setup

kotlin
// build.gradle.kts
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-test-manifest")

Every Compose UI test uses

code
ComposeTestRule
:

kotlin
@get:Rule
val composeTestRule = createComposeRule()

Or for full Activity testing (needed for navigation, theming, hilt):

kotlin
@get:Rule
val composeTestRule = createAndroidComposeRule<MainActivity>()

The Core Testing Model

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:

kotlin
// 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.


Finding Nodes

kotlin
// 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")

Setting Content and State

For component tests, set the content directly:

kotlin
@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:

kotlin
@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()
}

Interacting With UI Elements

kotlin
// 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() }

Asserting on State

kotlin
// 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")

A Complete Test Example

kotlin
@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()
}

Handling Async Operations

When your UI shows a loading state before content:

kotlin
@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()
}

Test Tags Best Practices

Add

code
testTag
to elements that are hard to find by text or content description:

kotlin
// 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

code
TestTags
object to avoid typos and keep them in sync between the composable and the test:

kotlin
object TestTags {
    const val TASK_LIST = "task_list"
    fun taskItem(id: String) = "task_item_$id"
}

Takeaways

  • Compose testing is semantic — find nodes by what they mean, not where they are
  • code
    testTag
    is your most stable selector — use it for non-text interactive elements
  • Set composable content directly in tests rather than driving a full Activity when possible
  • code
    waitUntil
    handles async loading states cleanly
  • The semantic tree that makes tests findable is the same tree used by TalkBack — accessible UI is testable UI
Share:
S

Sudarshan 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.

Stay updated

Get new posts on Android, Kotlin, and solo dev straight to your inbox.

Newsletter preferences

Related Apps

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.

Building something? Available for Android dev and QA consulting.

Work with me

Comments — powered by Giscus

Apps tagged with this