Compose Interoperability in Espresso Tests

During your migration to Compose, there are many moving parts to think about. How do we slot composables into existing Android Views? How do we manage the state between Android Views and Composable? Alongside these questions, it’s likely we’re going have other areas of our projects that are affected by compose migration.

While the Compose Interoperability APIs are pretty solid, we’re likely to run into some scenarios where we experience friction. One recent case for me was that after migrating an existing view in an Android layout to compose (using the ComposeView), the espresso tests interacting with that view were no longer working. I have seen a few reports of this being a known issue, so maybe it will be fixed in future – but for now, we may need to apply a workaround to get things working again. It’s likely this might be fixed in future, and there might be alternative ways to approach this, but I wanted to share a simple workaround I have applied using UIAutomator.


Let’s take a look at an example of an existing Android XML layout where I have slotted in a ComposeView.

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <TextView
        android:id="@+id/content_text"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Unpressed state" />

    <androidx.compose.ui.platform.ComposeView
        android:id="@+id/compose_view"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

</LinearLayout>

This is a pretty simple example, but it shows how we might begin adopting Compose into an existing Android layout. When it comes to composing content inside of this ComposeView, we might see something like the following:

val text = findViewById<TextView>(R.id.content_text)

findViewById<ComposeView>(R.id.compose_view).setContent {
    Button(onClick = {
        text.text = "Pressed State"
    }) {
        Text(text = "Button")
    }
}

Here we are composing a button, which changes the content of the Android TextView when the compose button is being interacted with. Ideally, here we would have managed state, but I’m keeping things simple for examples sake.


Now when it comes to our test suite, let’s say we’re currently testing two things.

  • That the default text is being displayed on the screen
  • That the content of the text is changed when the button is clicked

In our first test, we’re asserting the content of TextView from our layout. This test works as Espresso is accessing the content from the Android view system, so it can locate the content of the string that we have provided to the matcher.

@Test
fun Text_Is_Displayed() {
    onView(withText("Unpressed state"))
        .check(matches(isDisplayed()))
}

However, in our second test, we’re attempting to click on a component that has the text “Button”. This would have previously been working because the Button text was rendered as an Android View (and Espresso is able to detect that), but this test is now failing because we’ve switched this out for a composable, which Espresso doesn’t currently seem to be able to detect.

@Test
fun Text_Changes_After_Button_Press() {
    onView(withText("Button"))
        .check(matches(isDisplayed()))

    onView(withText("Pressed State"))
        .check(matches(isDisplayed()))
}

While there are compose testing APIs available, these are designed for use via a ComposeTestRule, which existing test classes will not likely be using, and we might not be in a position to migrate yet. While this is not the case and Espresso Android View matchers do not work with Compose, we can utilise UIAutomator to locate components within the UI and perform view interactions and/or assertions that way.

To use UI Automator, we need to add an additional dependency to our project:

androidTestImplementation 'com.android.support.test.uiautomator:uiautomator-v18:2.1.3'

Next, we need to retrieve a reference to the device that the tests are running on. For this, we can use the getInstance function from the UiDevice class.

private val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())

Now that we have access to the state of the device, we can use this to perform the required action on our Button. With access to the device, we can use the findObject function to locate a component within our UI. In this case, we want to find the component that has the “Button” text, which we’ll do so using the By.Text function. At this point, if found, we’ll have access to the Button component. We can then use the click function to perform a click operation

@Test
fun Text_Changes_After_Button_Press() {
    device.findObject(By.text("Button")).click()

    onView(withText("Pressed State"))
        .check(matches(isDisplayed()))
}

Now, our test will be working as we are interacting with the component based on the content on-screen using UIAutomator, as opposed to relying on either of the Espresso variants to work with one-another.


As mentioned above, this might work with future versions of the Espresso APIs, and the proposed workaround might be one of several approaches to re-enable testing during compose migration. If you have run into this issue yourself, and/or have other approaches, I’d love to hear about them!