Tomáš Repčík - 13. 2. 2024

Stop Using Test Tags in the Jetpack Compose Production Code

Replace test tags with semantics which bring more than just testing capabilities

UI testing with Jetpack Compose is mainly based on searching for nodes within the semantics tree, which is composed of the UI composables.

I have noticed some people that are using test tags as modifiers for their composable to make them easily reachable.

Here is a small example:

@Composable
fun PrimaryButton(
    modifier: Modifier = Modifier,
    @StringRes text: Int, onClick: OnClickFunction) {
    Button(
        // tag in the primary button
        modifier = modifier.testTag("Primary button"),
        onClick = onClick,
    ) {
        Text(
            stringResource(id = text),
        )
    }
}

In terms of quick development and first runs of tests, it is a viable route to make sure that everything is tested. Test tags are:

So far so good. However, it has huge downside:

It pollutes your production code with tags, which are intended for testing. Production code should not contain anything in regards to testing, at least to the minimal degree.

Other downsides:

There is a better way - additional semantics

Test tags do not provide anything besides the way how to search for the UI elements during UI testing. Semantics are used by Android to provide users, with special needs, with different ways to interact with your app. For example with TalkBack, screen reader, or Switch Access, which iterates through your UI and user picks the element with a clickable accessory.

You should stick to common ways of testing. Following options show additional ways how to search for nodes in semantic tree.

Content description

This is commonly known as everyone will hit it. Every Image or Icon in the Jetpack Compose ask you to fill out the contentDescription parameter. The input can be filled with some meaningful description and at the same time, it can be used within the test. If the image does not serve any purpose and it is just cosmetic, you can pass null and it will be ignored within semantics.

Implementation:

Image(
    painter = painterResource(R.drawable.important_image),
    // here you pass your description or null, if it is not important
    contentDescription = stringResource(id = R.string.important_image_description),
)

In test:

composeRule.onNode(hasContentDescription("Description of the image")).assertExists()

Clickable action description

Most of the time, you will be able to find a custom button via description or text. There is one extra layer, which can be helpful during testing and making your app accessible at the same time. You can use clickable or semantics field onClick with additional clickLabel and clickAction. The label informs the user about the action, which happens after clicking the composable.

// clickable modifier
Column(
    Modifier.clickable(
        onClickLabel = stringResource(R.string.on_button_click_label),
        onClick = {}
    )
) {}

// semantics modifier
Column(
    Modifier.semantics(
        onClick(
            label = stringResource(R.string.on_button_click_label), 
            action = {return@onClick true}
        )
    )
) {}

Semantics version expects to return boolean. It needs to know if the action is being handled.

There is no prebuilt semantic matcher for the clickable matcher, but we can create one.

// matcher based on the click label
fun hasClickLabel(label: String) = SemanticsMatcher("Clickable action with label: $label") {
    it.config.getOrNull(
        SemanticsActions.OnClick
    )?.label == label
}

// in test
composeRule.onNode(hasClickLabel("Moves to next screen")).assertExists()

I want you to encourage to go through other actions under SemanticsActions class, which can be used too.

State description

Most of the apps have some kind of state to show specific content for the user. You can use stateDescription to describe the current state on the screen, button or any other view. Implementation is quite straightforward. Based on your state handling mechanism, you can switch the stateDescription to fulfil your requirements. The state is then interpreted to the user.

@Composable
fun MainScreen(state: State, onClick: () -> Unit) {
    val turnedOffDescription = stringResource(id = R.string.turned_off_state_description)
    val turnedOnDescription = stringResource(id = R.string.turned_on_state_description)
    val waitingDescription = stringResource(id = R.string.waiting_state_description)

    Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxSize()) {
        CustomComposable(
            modifier = Modifier
                .semantics {
                    // definistion of state description based on state
                    stateDescription = when (state) {
                        State.TurnedOff -> turnedOffDescription
                        State.TurnedOn -> turnedOnDescription
                        State.WaitingForAction -> waitingDescription
                    }
                },
            state = state
        ) 
        /**
          * other composables
          */
    }
}

In test:

composeRule.onNode(hasStateDescription("Description of state")).assertExists()

Role of the composable

Accessibility services are used for the description role of composable. All the common composables like buttons, switches, checkboxes and others have the role included out of the box. However, all the composables come composed inside of other composables such as rows with text for example. It is desirable to make the whole element clickable, so it holds the current value. Here is an example with the switch button:

var currentValue by remember { mutableStateOf(false) }
Row(
    Modifier
        .toggleable(
            value = currentValue,
            role = Role.Switch,
            onValueChange = { currentValue = !currentValue }
        )
        .padding(8.dp)
        .fillMaxWidth()
) {
    Text("Setting description", Modifier.weight(1f))
    Switch(checked = currentValue, onCheckedChange = null)
}

Passing null to the switch makes it disabled. The state is controlled by toggleable modifier in row.

In the test, we can use the role in the following way:

fun hasRole(role: Role) = SemanticsMatcher("Searches for role: $role") {
    it.config.getOrNull(SemanticsProperties.Role) == role
}

composeRule.onNode(
    hasRole(Role.Switch).and(
        hasText("Setting description")
    )).assertExists()

Consistent semantics

If you have a complex composable, which behaves as one unit. It can be composable to house multi-category data like some events, headlines, etc. You can omit the semantics of all child composables and declare them in the parent composable once.

Don’t create semantics in multiple composables, if it is not needed:

val actionDescription = stringResource(id = R.string.actionDescription)
val contentDescription = stringResource(id = R.string.imageDescription)
Row {
    Image(
        painter = painterResource(R.drawable.important_image),
        contentDescription = contentDescription,
    ),
    PrimaryButton(
        modifier = Modifier.clickable(
            onClickLabel = clickActionLabel,
            onClick = {}
        )
    )
}

Create one consistent semantic declaration on top for one whole independent element:

val actionDescription = stringResource(id = R.string.actionDescription)
val contentDescription = stringResource(id = R.string.imageDescription)
Row(
    // row is parent of the composable - it owns the semantics
    modifier = Modifier.semantics {
        customActions = listOf(
            CustomAccessibilityAction(
                label = actionDescription, 
                // custom function
                { return@CustomAccessibilityAction true} 
            ),
        )
        contentDescription = contentDescription
    }
    
) {
    Image(
        painter = painterResource(R.drawable.important_image),
        contentDescription = null
    ),
    PrimaryButton(
        // to make sure, no semantics are addeed
        modifier = Modifier.clearAndSetSemantics { }
    )
}

This will keep your semantics explicit and clean in the code. The content description and action label can be found by SemanticMatcher as above.

To make your tests even better, more verbose and clean, you can check my other article about Robot patterns in Jetpack Compose.

Conclusion

Usage of semantics makes your app accessible to people with special needs and keeps your app testable at the same time. Unfortunately, there are situations, when the test tag is inevitable or it is just not worth it to invest time into semantics. On the bright side, I hope this will make it less challenging for you to implement them and make someone’s life simpler with your app.

More on semantics and accessibility can be found here.

Thanks for reading and do not forget to follow for more!

Subscribe for more
LinkedIn GitHub Medium Threads X Bluesky