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:
- easy to implement
- quickly testable
- no ambiguity
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:
- you need to keep them organized
- easily forgettable by a developer, if the code goes through refactoring
- consequently, the failure in tests needs to be assessed and can lead to a waste of time
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!