Jetpack Compose UI Testing: Semantics, Finders, Actions, and Assertions

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 item

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

Read more