Jetpack Compose UI Testing: Semantics, Finders, Actions, and Assertions
Jetpack Compose has its own testing APIs that don't use Espresso's view matchers. Instead, Compose tests work against the semantics tree — an accessibility-oriented representation of your UI that's separate from the rendering layer.
This makes Compose tests faster and less brittle than Espresso tests for the same UI. You're not poking at pixels or view IDs — you're querying structured semantic properties.
Setup
dependencies {
androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.6.0")
debugImplementation("androidx.compose.ui:ui-test-manifest:1.6.0")
}The ui-test-manifest dependency adds the test activity to your debug manifest. You need it to run Compose tests without a custom activity.
ComposeTestRule
Every Compose test uses a ComposeTestRule (or AndroidComposeTestRule for full activity tests):
class CounterTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun counterIncrementsOnClick() {
composeTestRule.setContent {
CounterScreen()
}
composeTestRule.onNodeWithText("Count: 0").assertIsDisplayed()
composeTestRule.onNodeWithContentDescription("Increment").performClick()
composeTestRule.onNodeWithText("Count: 1").assertIsDisplayed()
}
}setContent renders your composable in a test environment. No activity needed.
The Semantics Tree
Compose builds a semantics tree alongside the render tree. Every node in the semantics tree has properties like text, contentDescription, role, isEnabled, and more.
Inspect the semantics tree in a failing test:
composeTestRule.onRoot().printToLog("COMPOSE_TEST")This prints the full tree to logcat — invaluable for debugging why a finder can't locate a node.
Add semantics to your composables to make them testable:
Box(
modifier = Modifier.semantics {
contentDescription = "Product card for $productName"
role = Role.Button
}
) { ... }Finders
Finders select nodes from the semantics tree:
// By text
composeTestRule.onNodeWithText("Submit")
composeTestRule.onAllNodesWithText("Item")
// By content description
composeTestRule.onNodeWithContentDescription("Close dialog")
// By tag (test-only, doesn't affect accessibility)
composeTestRule.onNodeWithTag("login_button")
// By semantic matcher
composeTestRule.onNode(hasText("Submit") and isEnabled())
// Hierarchical
composeTestRule.onNodeWithTag("cart_list")
.onChildAt(0)
.onChildAt(1) // second child of first list itemTags are set with Modifier.testTag("my_tag"). They're invisible to users and exist only for tests.
Assertions
node.assertIsDisplayed()
node.assertIsEnabled()
node.assertIsNotEnabled()
node.assertIsFocused()
node.assertIsSelected()
node.assertTextEquals("Expected text")
node.assertContentDescriptionEquals("Description")
node.assertValueEquals("50%") // for sliders, progress bars
node.assertExists()
node.assertDoesNotExist()
// Count
composeTestRule.onAllNodesWithTag("cart_item").assertCountEquals(3)Actions
node.performClick()
node.performLongClick()
node.performScrollTo()
node.performTextInput("hello@example.com")
node.performTextClearance()
node.performTextReplacement("new text")
node.performImeAction() // triggers keyboard action (Submit, Next, etc.)
// Scroll lists
composeTestRule.onNodeWithTag("product_list")
.performScrollToIndex(10)
// Gestures
node.performTouchInput {
swipeLeft()
swipeUp()
longClick()
}Testing State and Recomposition
Compose tests automatically wait for recomposition after actions. You don't need explicit delays.
@Test
fun toggleExpandsContent() {
composeTestRule.setContent {
ExpandableSection(title = "Details")
}
composeTestRule.onNodeWithText("Content hidden").assertDoesNotExist()
composeTestRule.onNodeWithText("Details").performClick()
composeTestRule.onNodeWithText("Content hidden").assertIsDisplayed()
}After performClick(), the test waits until the UI is idle before running assertions.
For animations that complete asynchronously, use waitUntil:
composeTestRule.waitUntil(timeoutMillis = 3000) {
composeTestRule
.onAllNodesWithTag("loading_spinner")
.fetchSemanticsNodes()
.isEmpty()
}Testing with ViewModels and Hilt
For integration tests with real ViewModels:
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class ProfileScreenTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<MainActivity>()
@Inject
lateinit var userRepository: UserRepository
@Before
fun setUp() {
hiltRule.inject()
}
@Test
fun showsUserName() {
composeTestRule.onNodeWithTag("profile_name")
.assertTextEquals("Alice")
}
}Use createAndroidComposeRule<MainActivity>() when you need navigation or full activity context. Use createComposeRule() for isolated composable tests.
Testing Navigation
@Test
fun clickingProductNavigatesToDetail() {
val navController = TestNavHostController(
ApplicationProvider.getApplicationContext()
)
composeTestRule.setContent {
navController.navigatorProvider.addNavigator(ComposeNavigator())
AppNavGraph(navController = navController)
}
composeTestRule.onNodeWithTag("product_item_42").performClick()
assertThat(navController.currentBackStackEntry?.destination?.route)
.isEqualTo("product/{productId}")
}Semantic Matchers Reference
hasText("text")
hasContentDescription("desc")
hasTestTag("tag")
isEnabled()
isNotEnabled()
isDisplayed()
isFocused()
isSelected()
isToggleable()
isChecked()
isNotChecked()
hasClickAction()
hasScrollAction()
hasParent(matcher)
hasAnyChild(matcher)
hasAnySibling(matcher)Combine with and, or, not:
onNode(hasText("Confirm") and hasClickAction() and isEnabled())Testing Lists
@Test
fun listShowsAllProducts() {
val products = listOf(
Product(id = "1", name = "Widget A"),
Product(id = "2", name = "Widget B"),
Product(id = "3", name = "Widget C"),
)
composeTestRule.setContent {
ProductList(products = products)
}
composeTestRule.onAllNodesWithTag("product_item")
.assertCountEquals(3)
composeTestRule.onNodeWithText("Widget A").assertIsDisplayed()
composeTestRule.onNodeWithText("Widget C").assertIsDisplayed()
}Common Mistakes
Using Thread.sleep() — don't. Use waitUntil or let the test framework handle idle waiting.
Missing testTag — without tags or content descriptions, tests break when text changes. Tag key interactive elements.
Testing composable internals — test behavior, not implementation. Verify what users see and what happens when they act, not which sub-composables are called.
Asserting text that's split across nodes — a Text("Hello") and Text(" World") are two separate nodes. Use onNodeWithText("Hello") not onNodeWithText("Hello World").
Summary
Compose's testing APIs are well-designed: finders query semantics, actions drive interaction, and assertions verify state. The semantics tree gives you a stable, accessible layer to test against — independent of how the UI is rendered. Add testTag to interactive elements, use waitUntil for async state, and keep tests focused on behavior. Compose tests run faster than Espresso equivalents and break less often when you refactor UI code.