Article
Building Better Tests with Compose Semantics
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 . 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 . 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.