---
title: "Building Better Tests with Compose Semantics"
source: "/writing/building-better-tests-with-compose-semantics/"
---

Article

# Building Better Tests with Compose Semantics

Garrett Schafer

May 15, 2026

If you've ever done testing with Compose on Android, you've probably figured out that the only testable things are those that appear in the semantics tree. The downside here is that you're not only limited to the properties that exist in the tree, but also the `SemanticMatcher` filters that are provided to us that can be found in the `Filters.kt` file [here](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Filters.kt) . The one thing you can do is use an unmerged semantics tree, but testing things that way doesn't match how the semantic tree appears to things like TalkBack. We can go further with our Compose testing.

### Filters

The first thing you can do to improve your Composable testing is create additional filters that help find nodes based on existing semantic properties. You'd think there'd be a filter for each of the semantic properties, but I've found a lot of useful ones to be missing. One example of this is a filter for the `Role` property. If you want to find a node with specific `Role` value, you'd first want to create a filter like so.

fun hasRole(
  role: Role,
): SemanticsMatcher = SemanticsMatcher.expectValue(SemanticsProperties.Role, role)

With this filter, can you can find a node with the given role like so.

composeTestRule.onNode(hasRole(Role.Button))

### Finder

We can iterate on this and make this better. Using the filter, we can create a `SemanticsNodeInteractionsProvider` finder, similar to `onNodeWithText` or `onNodeWithContentDescription`. The available finders can be found in the `Finders.kt` file [here](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Finders.kt) . An example of a finder that uses the `hasRole` filter would look like so.

fun SemanticsNodeInteractionsProvider.onNodeWithRole(
  role: Role,
  useUnmergedTree: Boolean = false,
): SemanticsNodeInteraction = onNode(hasRole(role), useUnmergedTree)

This would make it so searching for a node on your test rule would look something like this.

composeTestRule.onNodeWithRole(Role.Button)

As you can see, this makes the test code a bit cleaner and easier to read. However, what if you have a whole screen of buttons, or a list of buttons, or you're just testing composables that in this case have multiple of the same role (or whatever semantic property you're testing for). Then you can use your filter to create an assertion to be used on nodes instead.

### Assertion

Similar to `SemanticsNodeInteraction` assertions such as `assertIsEnabled` and `assertHasClickAction`, you can create your own assertion to verify that a given node has a specific role. An assertion like `assertHasRole` would look something like this.

fun SemanticsNodeInteraction.assertHasRole(
  role: Role,
): SemanticsNodeInteraction = assert(hasRole(role))

which could then be used with your test rule like so

@Test
fun `swipeable button has button role`() {
  composeTestRule
    .onNodeWithText(SWIPE_BUTTON_TEXT)
    .assertHasRole(Role.Button)
}

You could see how creating these filters, finders, and assertions can help you test your composables further, or at least easier and cleaner. The `Role` semantic property was a relatively simple example, but I've found a couple others I've found useful as well I'd like to share.

### Progress Bar Indicator

If you ever have a progress indicator for a loading screen that doesn't have a progress value, the way you'd find the node with the given filters would look like so.

composeTestRule.onNode(
  hasProgressBarRangeInfo(
    ProgressBarRangeInfo(
      current = 0f,
      range = 0f..0f,
      steps = 0,
    )
  )
)

Not only does this look messy, but I've also found that the range info values can be inconsistent and cause tests to fail. In this case we don't necessarily care about the progress values of the indicator because it's just a general loading indicator (again, with no progress value). Instead of this approach, I've created a filter in the past just to check for any progress bar instead of a progress bar with a specific range info. That filter looks like so

fun hasProgressBarIndicator(): SemanticsMatcher =
  SemanticsMatcher.keyIsDefined(SemanticsProperties.ProgressBarRangeInfo)

Again, you can use this filter to create both a finder and an assertion, but this way you can just check to see if a node has a progress bar indicator or search for a node with any progress bar indicator. I've found my loading screen tests to be much more consistent with a filter like this. Your tests would end up looking much cleaner too.

composeTestRule
  .onNodeWithProgressBarIndicator()
  .assertIsDisplayed()

// or

composeTestRule
  .onNodeWithContentDescription("Loading")
  .assertHasProgressBarIndicator()

### CollectionInfo and CollectionItemInfo

Another useful example is when you're testing items in a list and want to check for `collectionItemInfo` properties or the correct `collectionInfo` property values. Creating a filter to check for the correct `collectionInfo` value is fairly easy and looks something like this

fun hasCollectionInfo(
  rowCount: Int,
  columnCount: Int,
): SemanticsMatcher =
  SemanticsMatcher("${SemanticsProperties.CollectionInfo.name} = '$rowCount, $columnCount'") {
    it.config
      .getOrElseNullable(SemanticsProperties.CollectionInfo, { null })
      ?.let { info ->
        info.rowCount == rowCount &&
          info.columnCount == columnCount
      }
      ?: false
 }

Then with the help of a finder, you can write a test to find the node with the correct values to ensure it exists.

fun SemanticsNodeInteractionsProvider.onNodeWithCollectionInfo(
  rowCount: Int,
  columnCount: Int,
  useUnmergedTree: Boolean = false,
): SemanticsNodeInteraction = onNode(
  hasCollectionInfo(rowCount, columnCount),
  useUnmergedTree,
)

composeTestRule
  .onNodeWithCollectionInfo(
    rowCount = 1,
    columnCount = listOfItems.count(),
  )
  .assertExists()

Then with a filter for the `collectionItemInfo`

fun hasCollectionItemInfo(
  rowIndex: Int,
  rowSpan: Int,
  columnIndex: Int,
  columnSpan: Int,
): SemanticsMatcher =
  SemanticsMatcher(
    "${SemanticsProperties.CollectionItemInfo.name} = '$rowIndex, $rowSpan, $columnIndex, $columnSpan'",
  ) {
    it
      .config
      .getOrElseNullable(SemanticsProperties.CollectionItemInfo, { null })
      ?.let { info ->
        info.rowIndex == rowIndex &&
          info.rowSpan == rowSpan &&
          info.columnIndex == columnIndex &&
          info.columnSpan == columnSpan
        }
      ?: false
  }

You can write tests to check that your items all have the correct `collectionItemInfo` too using an assertion.

fun SemanticsNodeInteraction.assertHasCollectionItemInfo(
  rowIndex: Int,
  rowSpan: Int,
  columnIndex: Int,
  columnSpan: Int,
): SemanticsNodeInteraction = assert(
  hasCollectionItemInfo(rowIndex, rowSpan, columnIndex, columnSpan),
)

listOfItems.forEachIndexed { index, item ->
  composeTestRule
    .onNodeWithText(item.title)
    .assertHasCollectionItemInfo(
      rowIndex = index,
      rowSpan = 1,
      columnIndex = 0,
      columnSpan = 1,
    )
}

You can see how creating additional filters, finders, and assertions can help you better test things in Compose. What do you do when you want to test things on your composables that don't have a semantic property? You could slap some `testTag` modifiers on composables to get them to show up in the semantic tree, but there's a better way. In my next article, I'll dive deeper into how we can create our own custom semantic properties and leverage those to further build out our composable tests.

-   Garrett Schafer

    Software Engineer

    [More from Garrett](/team/garrett-schafer/)

    Garrett Schafer

### Tags

[Digital Engineering](/insights/all/?topic=digital-engineering) [Mobile Apps](/insights/all/?topic=mobile-apps)

### Share

-   Copied

## View these next.

[See all](/insights/all/?type=writing)

-   [

    Article

    How To Sabotage Your Project Using Inconsistency

    Collin Flynn

    ](/writing/how-to-sabotage-your-project-using-inconsistency/)
-   [

    Article

    Unit Testing race conditions by creating chaos (Swift)

    Sean Berry

    ](/writing/unit-testing-race-conditions-by-creating-chaos-swift/)
-   [

    Article

    UIApplicationDelegate call sequence reference

    Sean Berry

    ](/writing/uiapplicationdelegate-call-sequence-reference/)
