---
title: "Composable Testing with Custom Semantic Properties"
source: "/writing/composable-testing-with-custom-semantic-properties/"
---

Article

# Composable Testing with Custom Semantic Properties

Garrett Schafer

October 7, 2026

Compose's semantics give us a great starting point for writing UI tests. With existing and [custom filters, finders, and assertions](https://livefront.com/writing/building-better-tests-with-compose-semantics/) , we can test many of the properties that already exist in the semantics tree. Beyond that, you may be tempted to start adding `testTag` modifiers. Test tags are easy to add and make composables easy to locate in the semantics tree during a UI test, however they only provide an identifier and not any other information about a component's state. There are better ways to add additional information to the semantic tree for better UI tests through the use of completely custom semantic properties.

### Custom Semantic Properties

Let me set the scene with an example. Imagine you have a component that's basically a status indicator. This indicator could include an icon followed by some text. Something like, "Success", "Warning", "Error", etc. You could add a content description to the icon and leave the icon and text not semantically merged, but that would result in a worse accessibility experience for the user. So normally in this case you would merge the text and icon semantically, negating the need for a content description on the icon itself because the text it is now merged with provides that context. Then, other than the text, there's no real way to test what icon is visible in your tests. That's where a custom semantic property could come in handy.

Imagine having a sealed class for all your icons, with data objects for each icon and a shared composable to render said icons, something like this.

sealed class MyIcon(
    @DrawableRes val id: Int,
) {
    sealed class Status(@DrawableRes id: Int) : MyIcon(id) {
        // MyIcon.Status.Success icon object
        data object Success : MyIcon(R.drawable.ic_status_success)

        // MyIcon.Status.Warning icon object
        data object Warning : MyIcon(R.drawable.ic_status_warning)

        // MyIcon.Status.Error icon object
        data object Error : MyIcon(R.drawable.ic_status_error)
    }

    // Composable to render icon, ie; MyIcon.Status.Success.ToIcon()
    @Composable
    fun ToIcon(
        modifier: Modifier = Modifier,
        contentDescription: String? = null,
        tint: Color = Color.Unspecified,
    ) {
        Icon(
            modifier = modifier,
            painter = rememberVectorPainter(id = id),
            contentDescription = contentDescription,
            tint = tint,
        )
    }
}

To start, you can create your own semantic property that you put on all your icon composables that then could be tested against. To create a semantic property, you need both a `SemanticsPropertyKey` and a `SemanticsPropertyReceiver`. So for your icons, it would look something like this.

val MyIconPropertyKey = SemanticsPropertyKey<MyIcon>("MyIcon")

var SemanticsPropertyReceiver.myIcon by MyIconPropertyKey

You could then add a semantic modifier on your icons to set that property to the icon object. Something like this

// icon: MyIcon
modifier = modifier.semantics { myIcon = icon }

You could even create a modifier extension to more easily set the property.

fun Modifier.myIcon(icon: MyIcon): Modifier = this.semantics {
    myIcon = icon
}

And then setting the property on the icon would look like so.

modifier = modifier.myIcon(icon)

Which is much cleaner in my opinion.

### Testing Your Custom Property

Once this is all set up, you can create a filter, finder, and assertion just like any other semantics property to make your tests super clean and easy to write. As an example, the filter, finder, and assertion for your icon property would look like so.

// Filter
fun hasIcon(
    icon: MyIcon,
): SemanticsMatcher = SemanticsMatcher.expectValue(MyIconPropertyKey, icon)

// Finder
fun SemanticsNodeInteractionsProvider.onNodeWithIcon(
    icon: MyIcon,
    useUnmergedTree: Boolean = false,
): SemanticsNodeInteraction = onNode(hasIcon(icon), useUnmergedTree)

// Assertion
fun SemanticsNodeInteraction.assertHasIcon(
    icon: MyIcon,
): SemanticsNodeInteraction = assert(hasIcon(icon))

You could then write your tests with something like this

composeTestRule
  .onNodeWithText("Success")
  .assertHasIcon(MyIcon.Status.Success)
composeTestRule
  .onNodeWithText("Warning")
  .assertHasIcon(MyIcon.Status.Warning)
composeTestRule
  .onNodeWithText("Error")
  .assertHasIcon(MyIcon.Status.Error)

// or
composeTestRule
  .onNodeWithIcon(MyIcon.Status.Success)
  .assertIsDisplayed()
composeTestRule
  .onNodeWithIcon(MyIcon.Status.Warning)
  .assertIsDisplayed()
composeTestRule
  .onNodeWithIcon(MyIcon.Status.Error)
  .assertIsDisplayed()

This could be done with test tags, but I think this looks much cleaner. As long as properties are kept small in size, this shouldn't add any more overhead or performance impact then a `testTag`. In the example above, adding the `MyIcon` object is relatively safe because it's just an object with a `@Drawable Int`. I'd avoid setting large objects as property values. The larger the property value, the larger the semantic tree, which could affect accessibility. For that reason I try to use this sparingly and in cases that could benefit from this kind of extra testing when it makes sense (and no different than when you'd use a `testTag` to test).

### Merge Policy

One thing that your custom semantic property does that the `testTag` does not do is merge semantically. The `[TestTagNode](http://compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/platform/TestTag.kt)` that inherits from `SemanticsModifierNode` has its `shouldMergeDescendantSemantics` set to false. This means that in your tests, to find a `testTag` of a child you need to use an unmerged tree. This is not the case for your property. Let me show you some examples of some simple semantic trees.

// using merged tree, test tag only
Node #1 at (l=0.0, t=0.0, r=320.0, b=68.0)px
 |-Node #2 at (l=16.0, t=16.0, r=304.0, b=52.0)px
   Text = '[Success]'
   Actions = [ClearTextSubstitution, GetTextLayoutResult, SetTextSubstitution, ShowTextSubstitution]
   MergeDescendants = 'true'

// using unmerged tree, test tag only
Node #1 at (l=0.0, t=0.0, r=320.0, b=68.0)px
 |-Node #2 at (l=16.0, t=16.0, r=304.0, b=52.0)px
   MergeDescendants = 'true'
    |-Node #3 at (l=16.0, t=16.0, r=41.0, b=40.0)px, Tag: 'Success Test Tag'
    |-Node #4 at (l=57.0, t=16.0, r=304.0, b=52.0)px
      Text = '[Success]'
      Actions = [ClearTextSubstitution, GetTextLayoutResult, SetTextSubstitution, ShowTextSubstitution]

// using merged tree, icon semantic property only
Node #1 at (l=0.0, t=0.0, r=320.0, b=68.0)px
 |-Node #2 at (l=16.0, t=16.0, r=304.0, b=52.0)px
   MyIcon = 'Success'
   Text = '[Success]'
   Actions = [ClearTextSubstitution, GetTextLayoutResult, SetTextSubstitution, ShowTextSubstitution]
   MergeDescendants = 'true'

// using merged tree, icon semantic property, and test tag
Node #1 at (l=0.0, t=0.0, r=320.0, b=68.0)px
 |-Node #2 at (l=16.0, t=16.0, r=304.0, b=52.0)px
   MyIcon = 'Success'
   Text = '[Success]'
   Actions = [ClearTextSubstitution, GetTextLayoutResult, SetTextSubstitution, ShowTextSubstitution]
   MergeDescendants = 'true'

// using unmerged tree, icon semantic property, and test tag
Node #1 at (l=0.0, t=0.0, r=320.0, b=68.0)px
 |-Node #2 at (l=16.0, t=16.0, r=304.0, b=52.0)px
   MergeDescendants = 'true'
    |-Node #3 at (l=16.0, t=16.0, r=41.0, b=40.0)px, Tag: 'Success Test Tag'
      MyIcon = 'Success'
    |-Node #4 at (l=57.0, t=16.0, r=304.0, b=52.0)px
      Text = '[Success]'
      Actions = [ClearTextSubstitution, GetTextLayoutResult, SetTextSubstitution, ShowTextSubstitution]

Something you might be wondering about is what happens if you merge multiple children with your custom semantic property together. I had the same question. While in the example above, it doesn't make the most sense to have multiple decorative icons on the same semantic node, it can happen. Imagine a more complicated card component with multiple icons whose meaning is communicated using things like `selected`, `disabled`, or `stateDescription` properties on the parent, again negating any need for a content description on said icons.

By default, the property takes the parent's value. So if you have two icons, it'll merge in the first icon property, giving the parent that property, and then by the time it gets to the second child, the parent property will be set and the child property will be ignored. We can change this behavior by changing the semantic property from a `MyIcon` object, to a `List<MyIcon>` and then using the `mergePolicy` to combine these properties into one list. First, your `SemanticsPropertyKey` would need updating like so

val MyIconPropertyKey = SemanticsPropertyKey<List<MyIcon>>(
  name = "MyIcon",
  mergePolicy = { parent, child ->
    parent.orEmpty() + child
  }
)

Then you'd want to update your Filter to something like the following.

fun hasIcon(
  icon: MyIcon,
): SemanticsMatcher {
  return SemanticsMatcher(
    "${MyIconPropertyKey.name} contains '${icon}'",
  ) { node ->
    node.config
      .getOrNull(MyIconPropertyKey)
      ?.contains(icon)
      ?: false
  }
}

Then if you merge multiple icon property children together, your tree would look something like this

Node #1 at (l=0.0, t=0.0, r=320.0, b=68.0)px
 |-Node #2 at (l=16.0, t=16.0, r=304.0, b=52.0)px
   MyIcon = '[Error, Disabled]'
   Text = '[Error]'
   Actions = [ClearTextSubstitution, GetTextLayoutResult, SetTextSubstitution, ShowTextSubstitution]
   MergeDescendants = 'true'

I don't typically do this with my custom properties. I haven't run into a case where multiple children with the same property are merged, but I just wanted to let you know that this is an option. I think if you're going this route, you should have a good reason, although I understand that it does seem tempting to make every property a list so you don't lose any semantic information from child nodes.

Custom semantic properties give us a way to add additional, meaningful information about the state of a composable to our semantics tree. They should be used intentionally when the built-in properties just aren't enough and add additional context to help write more robust UI tests. The icon and status indicator examples are just two potential use cases, but I'm curious to hear where else this approach could be useful. If you've used custom semantic properties in the past or have ideas for how you'd use this, I'd love to hear about it in the comments.

_Garrett works at Livefront, where the difference between good code and great code isn't just semantics._

-   Garrett Schafer

    Software Engineer

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

    Garrett Schafer

### Tags

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

### 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/)
