DEV Community

Cover image for How to test Jetpack Compose
Dmytro
Dmytro

Posted on • Edited on

How to test Jetpack Compose

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.

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")
Enter fullscreen mode Exit fullscreen mode

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))
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
...
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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.*)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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()
    }
Enter fullscreen mode Exit fullscreen mode

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()
    }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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.

  1. 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

Top comments (1)

Collapse
 
josealcerreca profile image
Jose Alcérreca

About the singleInstance problem: github.com/android/android-test/is...