Google is committed to advancing racial equity for Black communities. See how.

Testing your Compose layout

Testing a UI based on Compose is different from testing a view-based UI. The view-based UI toolkit clearly defines what a view is. A view occupies a rectangular space, and can be widgets or layouts. A view has properties, like identifiers, position, margin, padding, and so on.

Compose uses a different approach. Instead of View elements, you define composable functions which emit UI. Composables don't have an ID or a content description. How, then, do you do things like clicking a button in a UI test? This document explains the equivalent approaches for Compose.

Semantics

UI tests in Compose use semantics to interact with the UI hierarchy. Semantics, as the name implies, give meaning to a piece of UI. In this context, a "piece of UI" (or element) can mean anything from a single composable to a full screen. The semantics tree is generated alongside the UI hierarchy, and describes it.

Diagram showing a typical UI layout, and the way that layout would map to a corresponding semantic tree

Figure 1. The UI hierarchy and its semantics tree.

The semantics framework is primarily used for accessibility, so tests take advantage of the information exposed by semantics about the UI hierarchy. Developers decide what and how much to expose.

A button containing a graphic and text

Figure 2. A typical button containing an icon and text.

For example, given a button like this that consists of an icon and a text element, the default semantics tree only contains the text label "Like". This is because some composables, like Text, already expose some properties to the semantics tree. You can add properties to the semantic tree by using a Modifier.

Text(modifier = Modifier.semantics { text = text } )

You can override the default semantic output. For example, in this case you could have the button emit one node with the text label "Like button":

MyButton(modifier = Modifier.semantics { text = "Like button" } )

Setup

This section describes how to set up your module to let you test compose code.

First, add the following dependencies to the build.gradle file of the module containing your UI tests:

androidTestImplementation("androidx.ui:ui-test:$compose_version")

This module includes a ComposeTestRule and an implementation for Android called AndroidComposeTestRule. Through this rule you can set Compose content or access the activity. The typical UI test for Compose looks like this:

// file: app/src/androidTest/java/com/package/MyComposeTest.kt

class MyComposeTest {

    @get:Rule
    val composeTestRule = createAndroidComposeRule<MyActivity>()
    // createComposeRule() if you don't need access to the activityTestRule

    @Test
    fun MyTest() {
        // Start the app
        composeTestRule.setContent {
            MyAppTheme {
                MainScreen(uiState = exampleUiState, ...)
            }
        }

        onNodeWithText("Continue").performClick()

        onNodeWithText("Welcome").assertIsDisplayed()
    }
}

If you use createComposeRule, you'll have to add ComponentActivity to your Android Manifest. We recommend you do this in app/src/debug/AndroidManifest.xml:

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <application>
        <activity android:name="androidx.activity.ComponentActivity" />
    </application>
</manifest>

Testing APIs

If you have used Espresso, the testing APIs in Compose will feel familiar.

Compose uses SemanticsMatcher matchers to refer to one or more nodes in the semantics tree. Once you match one or more nodes, you can perform actions on them or make assertions.

Finders

Select a single node

onNode(<<SemanticsMatcher>>, useUnmergedTree = false): SemanticsNodeInteraction
// Example
onNode(hasText("Button")) // Equivalent to onNodeWithText("Button")

Select multiple nodes

onAllNodes(<<SemanticsMatcher>>): SemanticsNodeInteractionCollection
// Example
onAllNodes(hasText("Button")) // Equivalent to onAllNodesWithText("Button")

Using the unmerged tree

Some nodes merge the semantics information of their children. For example, a button with two text elements merges their labels:

Button { Text("Hello"), Text("World") }

From a test, we can use printToLog() to show the semantics tree:

onRoot().printToLog("TAG")

This code prints the following output:

Node #1 at (...)px
 |-Node #2 at (...)px
   OnClick = '...'
   Text = Hello World
   MergeDescendants = 'true'

If you need to match a node of what would be the unmerged tree, you can set useUnmergedTree to true:

onRoot(useUnmergedTree = true).printToLog("TAG")

This code prints the following output:

Node #1 at (...)px
 |-Node #2 at (...)px
   OnClick = '...'
   MergeDescendants = 'true'
    |-Node #3 at (...)px
    | Text = 'Hello'
    |-Node #5 at (83.0, 86.0, 191.0, 135.0)px
      Text = 'World'

The useUnmergedTree parameter is available in all finders. For example, here it's used in an onNodeWithText finder.

onNodeWithText("World", useUnmergedTree = true).assertIsDisplayed()

Assertions

Check assertions by calling assert() on the SemanticsNodeInteraction returned by a finder with one or multiple matchers:

// Single matcher:
onNode(...).assert(hasText("Button")) // hasText is a SemanticsMatcher
// Multiple matchers can use and / or
onNode(...).assert(hasText("Button") or hasText("Button2"))

There are also functions to check assertions on a collection of nodes:

// Check number of matched nodes
onAllNodesWithLabel("Beatle").assertCountEquals(4)
// At least one matches
onAllNodesWithLabel("Beatle").assertAny(hasTestTag("Drummer"))
// All of them match
onAllNodesWithLabel("Beatle").assertAll(assertHasClickAction())

Actions

To inject an action on a node, call a perform…() function:

onNode(...).performClick()

Here are some examples of actions:

performClick(),
performSemanticsAction(),
performKeyPress(),
performGesture { swipeLeft() }

Matchers

This section describes some of the matchers available for testing your Compose code.

Hierarchical matchers

Hierarchical matchers let you go up or down the semantics tree and perform simple matching.

fun hasParent(matcher: SemanticsMatcher): SemanticsMatcher
fun hasAnySibling(matcher: SemanticsMatcher): SemanticsMatcher
fun hasAnyAncestor(matcher: SemanticsMatcher): SemanticsMatcher
fun hasAnyDescendant(matcher: SemanticsMatcher):  SemanticsMatcher

Here are some examples of these matchers being used:

onNode(hasParent(hasText("Button"))
    .assertIsVisible()

Selectors

An alternative way to create tests is to use selectors which can make some tests more readable.

onNode(hasTag("Players"))
    .onChildren()
    .filter(hasClickAction())
    .assertCountEquals(4)
    .onFirst()
    .assert(hasText("John"))

Common patterns

This section describes some common approaches you'll see in Compose testing.

Testing in isolation

ComposeTestRule lets you start an activity displaying any composable: your full application, a single screen, or a widget. It's also a good practice to check that your composables are correctly encapsulated and they work independently, allowing for easier and more focused UI testing.

This doesn't mean you should only create unit UI tests. UI tests scoping larger parts of your UI are also very important.

Simulating configuration changes

Configuration in Compose is provided via ambients. Ambients describe the characteristics of the device that your app is running on. Whenever the configuration changes, the ambient recomposes the relevant part of the tree. Configuration contains information like screen size and orientation, night mode, and so on. Instead of asking the device to change these parameters, it's much faster and more reliable if you simulate the configuration change in Compose:

class MyTest() {

    private val themeIsDark = MutableStateFlow(false)

    @Before
    fun setUp() {
        composeTestRule.setContent {
            JetchatTheme(
              isDarkTheme = themeIsDark.collectAsState(false).value
            ) {
                MainScreen()
            }
        }
    }

    @Test
    fun changeTheme_scrollIsPersisted() {
        findByLabel("Continue").doClick()

        // Set theme to dark
        themeIsDark.value = true

        // Check that we're still on the same page
        findByLabel("Welcome").assertIsDisplayed()
    }
}

Debugging

The main way to solve problems in your tests is to look at the semantics tree. You can print the tree by calling findRoot().printToLog() at any point in your test. This function prints a log like this:

    Node #1 at (...)px
     |-Node #2 at (...)px
       OnClick = '...'
       MergeDescendants = 'true'
        |-Node #3 at (...)px
        | Text = 'Hi'
        |-Node #5 at (83.0, 86.0, 191.0, 135.0)px
          Text = 'There'

These logs contain valuable information for tracking bugs down.

Disabling animations

Unless your test is checking the correct behavior of animations, you probably want to disable animations to let your test run as fast as possible. Animations can slow down testing considerably, so AndroidComposeTestRule provides a parameter to disable all animations:

@get:Rule
val composeTestRule =
  AndroidComposeTestRule<MyActivity>(disableTransitions = true)

There is no need to set the transition and animation system settings in Developer Options anymore.