Join the Newsletter

Subscribe to get the latest content by email.

    I won't send you spam. Unsubscribe at any time.

    How to test Jetpack Compose UI

    Jetpack Compose series:

    *Testing Jetpack Compose UI talk for droidcon Berlin is available to complement this material.

    UI created with XML is traditionally tested with Espresso and UIAutomator. However, Jetpack Compose constructs UI in differently and the usual tools can’t handle some of its specifics.

    cover image

    Jetpack Compose vs XML

    Composable instead of View. Jetpack Compose constructs UI with Composables and doesn’t use Android Views. Composable is also a UI element. It has semantics that describes its attributes. All composables are combined in a single UI tree with semantics that describes its children.

    Compose Layout doesn’t have IDs and tags. Instead, there’s the testTag attribute in semantics that allows you to add a unique identifier to a Composable.

    Different testing tools. Espresso and UIAutomator can still test a Compose Layout - searching by text, resource, etc. However, they don’t have access to Composables’ semantics and can’t fully test them. Therefore, it’s recommended to use the Jetpack Compose testing library as it can access semantics and fully test Composables on the screen.

    Compose tests are synchronized by default. Moreover, they don’t run in real time, but use a virtual clock so they can pass as fast as possible.

    Use AndroidComposeTestRule or ComposeTestRule test rule.

    Getting started

    Add testing dependencies to the build.gradle file:

    def compose_version = '1.0.1'
    androidTestImplementation("androidx.compose.ui:ui-test-junit4:$compose_version")
    debugImplementation("androidx.compose.ui:ui-test-manifest:$compose_version")

    Let’s assume we have a screen with a single button.

    button screen

    The layout for this screen:

    @Composable
    fun MainScreen() {
    Box(
    contentAlignment = Alignment.Center,
    modifier = Modifier
    .fillMaxWidth()
    .fillMaxHeight()
    .background(Color.White)
    ) {
    Button(
    onClick = {...},
    modifier = Modifier.testTag("yourTestTag")
    ) {
    Text(text = stringResource(R.string.click))
    }
    }
    }

    Now we should make sure it is displayed on the screen and then click on it. How is this done?

    Testing the screen

    To test the screen we first need to open a test class in the androidTest folder.
    Create a testRule with createAndroidTestRule. Pass the activity class that holds the UI:

    class ExampleInstrumentedTest {

    @get:Rule
    val composeTestRule = createAndroidTestRule(MainActivity::class.java)
    ...

    Now write a test where the button is found by its testTag. Check that it is displayed and then click on it.

    @Test
    fun testButtonClick() {
    val button = composeTestRule.onNode(hasTestTag("yourTestTag"), useUnmergedTree = true)
    button.assertIsDisplayed()
    button.performClick()
    }

    In this test we have:
    composeTestRule - a TestRule to test UI created with Compose
    onNode - a finder
    hasTestTag - a Matcher
    useUnmergedTree - a parameter that controls UI tree hierarchy representation
    asssertExists - an assertion
    performClick - an action

    Now each step in detail

    composeTestRule

    composeTestRule finds the UI element by its semantics attributes such as testTag, content description, or a custom property of a Composable. It has access the entire semantics tree of the UI that is on a screen.

    Finders

    Finders look for the Composable with a matching criterion and return a SemanticsNodeInteraction that holds the Composable and its children if there are any.

    Some common finders:

    • onNode - looks for a single Composable that matches the searching criteria. Throws an exception if more than one matching Composable is found.
    • onAllNodes - looks for all nodes with a matching criterion. Returns a non-iterable SemanticsNodeInteractionCollection that holds found Composables and its possible children.
    • onNodeWithTag - looks for a single Composable with the specified testTag
    • onNodeWithText - looks for a single Composable with the specified text. A localized string can be searched by retrieving it with
    androidComposeTestRule.activity.getString(R.string.*)

    Matchers

    A matcher specifies the criteria a finder uses to find the Composable. For example:

    • hasContentDescription - verifies that the Composable has specified content description.
    • hasTestTag - verifies that the Composable has the specified test tag.
    • isRoot - verifies that it is the root Composable.

    There are also hierarchical matchers and selectors.

    Hierarchical matchers verify the position of the Composable in the UI tree with methods like hasParent() or hasAnyChild().

    Selectors can figure out Composables around and filter them.
    For example, given the following tree:

    |-Root composable
      |-ButtonOne
      |-ButtonTwo
      |-ButtonThree
    

    calling onSiblings() on ButtonTwo will return buttonOne and buttonThree Composables.

    The full list of matchers is below.

    useUnmergedTree

    Compose layout flattens its UI tree so some UI elements can be combined into a single Composable. For example, 2 texts can be merged into a single Text Composable. Thus, some semantics can be lost. In order to inspect an intact UI tree useUnmergedTree should be true.

    Assertions

    They verify that the Composable meets a specific condition.

    Some common assertions:

    • assertExists
    • assertIsEnabled
    • assertTextEquals
    • assertContentDescription

    Using generic assert(), you can provide your matcher and verify that it is satisfied for this node.

    Actions

    Actions simulate user events on Composable such us:

    • performClick
    • performScroll
    • performTextInput

    It also supports different kinds of gestures.

    The full list of Finders, Matchers, Assertions, and Actions can be found in Jetpack Compose testing cheatsheet.

    Testing only layout

    Jetpack Compose also allows testing only the layout itself instead of the entire app.
    To do this use createComposeRule instead of createAndroidComposeRule.

    @get:Rule
    val composeTestRule = createComposeRule()

    And then set the layout Composable(MainScreen) right in the test:

        @Test
    fun testButtonClick() {
    composeTestRule.setContent {
    MyAppTheme {
    MainScreen()
    }
    }
    val button = composeTestRule.onNode(hasTestTag("yourTestTag"), true)
    button.assertIsDisplayed()
    button.performClick()
    }

    It is even possible to create the UI right inside the test:

        @Test
    fun testButtonClick() {
    composeTestRule.setContent {
    Column {
    Button(
    onClick = {...},
    modifier = Modifier.testTag("yourTestTag")
    ) {
    Text(text = "Click")
    }
    }
    }
    val button = composeTestRule.onNode(hasTestTag("yourTestTag"), true)
    button.assertIsDisplayed()
    button.performClick()
    }

    Q&A

    Is Composable compatible with View?

    • Yes, they are interoperable. It is possible to add an Android View to Composable and vice versa.

    Can I use Espresso and UIAutomator to test UI created with Jetpack Compose?

    • Yes. You can search on the UI by text or resource to find the elements and interact with them.

    What’s the difference between createComposeRule and createAndroidComposeRule?

    • createAndroidComposeRule is an Android-specific TestRule as it holds a reference to the activity it runs.
      createComposeRule is crossplatform and has no ties to Android.

    Troubleshooting

    Blank screen when testing Jetpack Compose UI

    androidx.test.core.app.InstrumentationActivityInvoker
    androidx.test.core.app.InstrumentationActivityInvoker

    It took me a good deal of time to figure out what was the root cause of the issue. Turned out, UI tests for Compose cannot run properly if the tested activity launchMode is singleInstance

    android:launchMode="singleInstance"

    Removing this attribute will fix the issue. However, if it’s not an option, then there are a few other ways to fix it:

    1. Override/remove the attribute for UI test with a different manifest

      When assembling an app, Gradle merges manifests that your app, dependencies, and modules may have.
      You can override or remove completely the android:launchMode attribute by node markers.
      By default, androidTest runs in the debug build type. So adding a proper node marker to Manifest in the/debug directory will override it for the app used by androidTest tests.

      ⚠️ However, it will also affect ordinary debug builds. Read further if it’s undesirable.

    2. Create a separate build variant for Compose UI tests

      Just making a separate build type solves the issue and also keeps UI tests available for multiple app flavors.
      Don’t forget adding testBuildType “staging” // TODO Update this line

      ⚠️ Build type can’t be named compose as it is a reserved word.

    lateinit property remeasurement has not been initialized in LazyList

    In my case, the root cause of the issue was due to using the wrong scrolling functionality in LazyList.

    I could only fix the issue using animateScrollToItem(index) instead of scrollToItem(index).

    Further reads

    Testing with Compose Layout

    Android Codelabs for Jetpack Compose Testing

    Testing cheatsheet

    Android Developers Backstage: Episode 171: Compose Testing