This document explores Jetpack Compose AndroidView
component compatibility with common large-scale E2E testing platforms (Appium + Pytest).
Target Audience: Android developers and E2E test developers
Photo by National Cancer Institute on Unsplash
Background
A key challenge when introducing Composables into a View-based codebase is ensuring compatibility with existing tools, particularly E2E testing platforms. A typical setup involves PyTest integrated with Appium WebDriver, which utilizes Selenium WebDriver.
Appium provides AppiumBy
to locate and interact with mobile UI elements:
class AppiumBy(By): IOS_PREDICATE = '-ios predicate string' IOS_CLASS_CHAIN = '-ios class chain' ANDROID_UIAUTOMATOR = '-android uiautomator' ANDROID_VIEWTAG = '-android viewtag' ANDROID_DATA_MATCHER = '-android datamatcher' ANDROID_VIEW_MATCHER = '-android viewmatcher' ACCESSIBILITY_ID = 'accessibility id' IMAGE = '-image' CUSTOM = '-custom'
AppiumBy enables element searches using attributes like Accessibility ID, ViewTag, and Class Chain. By setting the same Accessibility ID on both native Android and iOS apps, we would be able to write and execute one test for both platforms.
In Jetpack Compose, contentDescription
is crucial for making Composables locatable by Appium:
@Composable private fun ShareButton(onClick: () -> Unit) { IconButton(onClick = onClick) { Icon( imageVector = Icons.Filled.Share, contentDescription = "shared_e2e_accessibility_id" ) } }
So far, we have made our View-Based component and Composables Locatable by Appium driver whilst keeping it backward compatible.
The Problem
Challenges arise when dealing with hybrid View-Compose components, such as AndroidView
or ComposeView
. Setting contentDescription
on the outer wrapper element does not automatically apply it to the inner element.
This is a more common issue with large codebases where developers use shared components more often. E.g. They’ll have a Composable and setting a contentDescription on it seems to be the only step to pass E2E tests but if that’s an AndroidView internally, in fact this attribute will not be delivered to the very container.
Sometimes we don’t have edit access to the wrapped item as we are consumers of it. So it’s recommended to expose the
contentDescription
APIs when you share a component.
Job Offers
Working solution
Here is an example of the inner component implementation for the Button
Composable and an example of an approach to resolve this issue:
@Composable fun Button( text: String, modifier: Modifier = Modifier, ... ) { AndroidView(modifier = modifier, factory = { context -> Button(context = context).apply { setText(text) textColor?.let { setTextColor(textColor) } var accessibilityId: String? = null this.foldIn(null as String?) { _, element -> if (element is SemanticsModifier) { element.semanticsConfiguration.getOrNull("contentDescription")?.let { accessibilityId = it } } accessibilityId } contentDescription = accessibilityId } }) }
Here, modifier is an exposed parameter, so consumers can set semantics and send it.
On the consumer side, here is how it can be used:
@Composable fun LoginButtonsSet( modifier: Modifier = Modifier, ... ) { Button( text = stringResource(...), modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp) .semantics { contentDescription = 'btn_login_signup' }, ) ....
Once it’s set, on the E2E side, you can use these methods to find the element:
def get_sign_up_button_element(self): return find_element(AppiumBy.ACCESSIBILITY_ID, "btn_login_signup") get_sign_up_button_element().click()
Next Steps
To simplify this process, consider creating extension functions:
Modify.getContentDescription
: to read contentDescription from any modifier object.Modifier.setContentDescription
: to set it on the modifier.
This approach enhances readability and reduces boilerplate code. Here is a simple implementation of it.
utils/AccessibilityModifier.kt
import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.SemanticsModifier import androidx.compose.ui.semantics.SemanticsPropertyKey import androidx.compose.ui.semantics.SemanticsPropertyReceiver import androidx.compose.ui.semantics.getOrNull import androidx.compose.ui.semantics.semantics /** * Key for the contentDescription semantics property. * If your composable works with E2E or UI tests, * you can use this property to set a unique identifier for the composable. */ /** * Key for the contentDescription semantics property. */ val ContentDescription = SemanticsPropertyKey<String>("ContentDescription") /** * Receiver for the contentDescription semantics property. */ var SemanticsPropertyReceiver.contentDescription by ContentDescription /** * Sets the contentDescription of a composable. * * @param contentDescription The contentDescription to set. */ fun Modifier.contentDescription(contentDescription: String): Modifier { return semantics { this.contentDescription = contentDescription } } /** * Gets the contentDescription of a composable. * * @return The contentDescription, or null if not set. */ fun Modifier.getContentDescription(): String? { return this.foldIn(null as String?) { _, element -> if (element is SemanticsModifier) { element.semanticsConfiguration.getOrNull(ContentDescription)?.let { return@foldIn it } } } }
. . .
By carefully considering accessibility and adopting best practices for setting contentDescription
within your Jetpack Compose components, you can ensure seamless integration with your existing E2E testing infrastructure. This approach promotes maintainability and simplifies the transition to a Compose-based UI architecture while preserving the reliability of your automated tests.
I hope this exploration provides valuable insights for your Jetpack Compose adoption journey.
This article is previously published on proandroiddev.com.