Efficient Testing or Code Pollution?The Role of .testTag()
in Jetpack Compose Testing
As Android developers, many of us (including myself) face the challenge of adapting our testing strategies when transitioning from XML-based UI layouts to Jetpack Compose.
In the XML world, id
attributes in views served as reliable anchors for UI testing, allowing tools like Espresso to locate and interact with elements efficiently.
With Jetpack Compose, the traditional id
is no longer part of the toolkit, raising questions about how to achieve similar efficiency in testing. One solution offered by Jetpack Compose is the .testTag()
modifier, but its use has sparked debate. Is it the natural successor to id
, or does it unnecessarily clutter production code?
The Role of `id` in XML Views
In XML-based Android development, id
attributes were central to how we structured and interacted with UI components. These id
s allowed us to reference views programmatically and played a crucial role in UI testing with frameworks like Espresso.
🧑💻 XML Layout with id
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<Button
android:id="@+id/button_submit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Submit"/>
</RelativeLayout>
🧑💻 Espresso Test Using id
class MainActivityTest {
@Test
fun testButtonClick() {
onView(withId(R.id.button_submit)).perform(click())
}
}
In this approach, the id
was a consistent and reliable way to locate UI components, making tests easy to write and maintain.
Jetpack Compose and the Introduction of `.testTag()`
With Jetpack Compose, the concept of id
is no longer necessary due to its declarative nature. However, this creates a challenge when writing tests that need to locate specific components.
To address this, Jetpack Compose introduces .testTag()
, which allows developers to assign tags to composables for identification in tests.
🧑💻 Using .testTag()
in a Composable Function
@Composable
fun SubmitButton() {
Button(
onClick = { /* TODO */ },
modifier = Modifier.testTag("submitButton")
) {
Text("Submit")
}
}
🧑💻 Testing with .testTag()
class SubmitButtonTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun testButtonClick() {
composeTestRule.setContent {
SubmitButton()
}
composeTestRule
.onNodeWithTag("submitButton")
.performClick()
}
}
Here, .testTag()
provides a convenient way to identify composables during testing.
Some developers argue that this approach "pollutes" production code with test-specific logic.
The rationale is that production code should remain free of testing concerns, ensuring it stays clean and maintainable.
The developer community speculates that tools like R8 might strip .testTag()
out of release builds, alleviating concerns about code pollution. However, finding definitive documentation or evidence supporting this claim has been challenging.
Alternatives to .testTag()
If we decide against using .testTag()
, there are other ways to locate UI components in Compose tests. Here are a few alternatives:
1. Using Semantic Properties
Jetpack Compose allows us to add semantic properties to composables, which can be used for accessibility and testing. This is the most common approach I have seen at workplaces in the UK, but it has drawbacks.
🧑💻 Assigning a contentDescription
to a button and using it in a test
@Composable
fun SubmitButton() {
Button(
onClick = { /* TODO */ },
modifier = Modifier.semantics { contentDescription = "Submit Button" }
) {
Text("Submit")
}
}
🧑💻 Testing with Semantic Properties
composeTestRule
.onNodeWithContentDescription("Submit Button")
.performClick()
Issues to look out for:
- Localisation Issues: If the
contentDescription
is hardcoded, it can lead to problems in apps supporting multiple languages. While countries like the UK that only deliver apps in English may not be aware, changes in the locale in test environments can break tests, necessitating additional maintenance. - ⚠️ Impact on Accessibility: Overusing or improperly crafting semantic descriptions solely for testing purposes can negatively affect the accessibility experience for users who rely on assistive technologies. The semantics tree utilised in Compose is shared between testing frameworks and accessibility services like TalkBack. Introducing excessive or irrelevant semantic information can clutter the accessibility output, making it more challenging for users with disabilities to navigate and understand the app effectively.
2. Using Text Content
Another common approach is to identify components based on their text content.
composeTestRule.onNodeWithText("Submit").performClick()
- Like
contentDescription
, relying on text content can be fragile, especially in applications where text changes frequently or is localised.
3. Using Hierarchical and Structural Matching
We can use advanced matching techniques based on the structure of our UI or the types of components.
composeTestRule
.onAllNodes(hasText("Submit"))
.filter(hasClickAction())
.performClick()
- While this method is powerful, it can become complex and harder to maintain, especially in large or dynamic UIs.
.testTag(): A Taboo or an Option?
Let’s quote the following found in the official Jetpack Compose testing code lab, which mentions the use of .testTag()
:
Warning: Composables don’t have IDs and you can’t use the Node numbers shown in the tree to match them. If matching a node with its semantics properties is impractical or impossible, you can use the
testTag
modifier with thehasTestTag
matcher as a last resort.
This statement highlights the utility of .testTag()
when other methods of identifying components are not feasible, reinforcing its role as a practical tool in Compose testing.
As a personal note, imagine if .testTag()
were officially renamed to .id()
by Google. Would you feel more comfortable using it in production code and incorporating it into your tests? 🙂
Conclusion
Using .testTag()
in Jetpack Compose testing raises important questions about balancing efficiency and code cleanliness. On the one hand, .testTag()
offers a simple and effective way to ensure robust UI tests, much like the id
attribute did in XML layouts. On the other hand, concerns about code pollution persist despite the possibility that .testTag()
might be stripped out in production builds. (I hope Google can clarify this in their documentation!)
We know about the testing pyramid and how complicated and time-consuming it can be to write UI/end-to-end tests. A few years ago, in an interview with the CEO of a startup company, we discussed the challenges of following the testing pyramid. While this pyramid is important, we acknowledged that the time and resources required to write UI/end-to-end tests can sometimes be excessive. In a realistic sense, some companies look for individuals who can find a balance between good practices and business efficiency. As projects gradually migrate from XML views to Jetpack Compose, making decisions that strike this balance will become increasingly difficult and tricky.
So, as we consider our testing strategies in Jetpack Compose, ask ourselves:
Is the convenience and reliability of
.testTag()
worth integrating into our codebase, or do we prefer the purity of alternative methods, even if they might be more complex and prone to errors?
The choice is yours—what approach will you take? 🙂