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. A typical 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.

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

You can read more about how Semantics properties are used to improve your app's accessibility.

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:

// Test rules and transitive dependencies:
androidTestImplementation("androidx.compose.ui:ui-test-junit4:$compose_version")
// Needed for createComposeRule, but not createAndroidComposeRule:
debugImplementation("androidx.compose.ui:ui-test-manifest:$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, /*...*/)
            }
        }

        composeTestRule.onNodeWithText("Continue").performClick()

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

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

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

Select multiple nodes

composeTestRule.onAllNodes(<<SemanticsMatcher>>): SemanticsNodeInteractionCollection
// Example
composeTestRule.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:

MyButton {
    Text("Hello")
    Text("World")
}

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

composeTestRule.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:

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

composeTestRule.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:
composeTestRule.onNode(matcher).assert(hasText("Button")) // hasText is a SemanticsMatcher
// Multiple matchers can use and / or
composeTestRule.onNode(matcher).assert(hasText("Button") or hasText("Button2"))

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

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

Actions

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

composeTestRule.onNode(...).performClick()

Here are some examples of actions:

performClick(),
performSemanticsAction(key),
performKeyPress(keyEvent),
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:

composeTestRule.onNode(hasParent(hasText("Button")))
    .assertIsDisplayed()

Selectors

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

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

Synchronization

Compose tests are synchronized by default with your UI. When you call an assertion or an action via the ComposeTestRule, the test will be synchronized beforehand, waiting until the UI tree is idle.

Normally, you don't have to take any action. However, there are some edge cases you should know about.

When a test is synchronized, your Compose app is advanced in time using a virtual clock. This means Compose tests don't run in real time, so they can pass as fast as possible.

However, if you don't use the methods that synchronize your tests, no recomposition will occur and the UI will appear to be paused.

@Test
fun counterTest() {
    val myCounter = mutableStateOf(0) // State that can cause recompositions
    var lastSeenValue = 0 // Used to track recompositions
    composeTestRule.setContent {
        Text(myCounter.value.toString())
        lastSeenValue = myCounter.value
    }
    myCounter.value = 1 // The state changes, but there is no recomposition

    // Fails because nothing triggered a recomposition
    assertTrue(lastSeenValue == 1)

    // Passes because the assertion triggers recomposition
    composeTestRule.onNodeWithText("1").assertExists()
}

It's also important to note that this requirement only applies to Compose hierarchies, and not to the rest of the app.

Disabling automatic synchronization

When you call an assertion or action through the ComposeTestRule such as assertExists(), your test is synchronized with the Compose UI. In some cases you might want to stop this synchronization and control the clock yourself. For example, you can control time to take accurate screenshots of an animation at a point where the UI would still be busy. To disable automatic synchronization, set the autoAdvance property in the mainClock to false:

composeTestRule.mainClock.autoAdvance = false

Typically you will then advance the time yourself. You can advance exactly one frame with advanceTimeByFrame() or by a specific duration with advanceTimeBy():

composeTestRule.mainClock.advanceTimeByFrame()
composeTestRule.mainClock.advanceTimeBy(milliseconds)

Idling resources

Compose can synchronize tests and the UI so that every action and assertion is done in an idle state, waiting or advancing the clock as needed. However, some asynchronous operations whose results affect the UI state can be run in the background while the test is unaware of them.

You can create and register these idling resources in your test so that they're taken into account when deciding whether the app under test is busy or idle. You don't have to take action unless you need to register additional idling resources, for example, if you run a background job that is not synchronized with Espresso or Compose.

This API is very similar to Espresso's Idling Resources to indicate if the subject under test is idle or busy. You use the Compose test rule to register the implementation of the IdlingResource.

composeTestRule.registerIdlingResource(idlingResource)
composeTestRule.unregisterIdlingResource(idlingResource)

Manual synchronization

In certain cases, you have to synchronize the Compose UI with other parts of your test or the app you're testing.

waitForIdle waits for Compose to be idle, but it depends on the autoAdvance property:

composeTestRule.mainClock.autoAdvance = true // default
composeTestRule.waitForIdle() // Advances the clock until Compose is idle

composeTestRule.mainClock.autoAdvance = false
composeTestRule.waitForIdle() // Only waits for Idling Resources to become idle

Note that in both cases, waitForIdle will also wait for pending draw and layout passes.

Also, you can advance the clock until a certain condition is met with advanceTimeUntil().

composeTestRule.mainClock.advanceTimeUntil(timeoutMs) { condition }

Note that the given condition should be checking the state that can be affected by this clock (it only works with Compose state). Any condition that depends on Android's measure or draw (that is, measure or draw external to Compose) should use more general concept such as waitUntil():

composeTestRule.waitUntil(timeoutMs) { condition }

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() {
        composeTestRule.onNodeWithContentDescription("Continue").performClick()

        // Set theme to dark
        themeIsDark.value = true

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

Custom semantics properties

You can create custom semantics properties to expose information to tests. To do this, define a new SemanticsPropertyKey and make it available using the SemanticsPropertyReceiver.

// Creates a Semantics property of type boolean
val PickedDateKey = SemanticsPropertyKey<Long>("PickedDate")
var SemanticsPropertyReceiver.pickedDate by PickedDateKey

Now you can use that property using the semantics modifier:

val datePickerValue = remember { mutableStateOf(0L) }
MyCustomDatePicker(
    modifier = Modifier.semantics { pickedDate = datePickerValue }
)

From tests, you can use SemanticsMatcher.expectValue to assert the value of the property:

composeTestRule
    .onNode(SemanticsMatcher.expectValue(PickedDateKey, 1445378400)) // 2015-10-21
    .assertExists()

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.

Interoperability with Espresso

In a hybrid app you can find Compose components inside view hierarchies and views inside Compose composables (via the AndroidView composable).

There are no special steps needed to match either type. You match views via Espresso's onView, and Compose elements via the ComposeTestRule.

@Test
fun androidViewInteropTest() {
    // Check the initial state of a TextView that depends on a Compose state:
    Espresso.onView(withText("Hello Views")).check(matches(isDisplayed()))
    // Click on the Compose button that changes the state
    composeTestRule.onNodeWithText("Click here").performClick()
    // Check the new value
    Espresso.onView(withText("Hello Compose")).check(matches(isDisplayed()))
}

Learn more

To learn more try the Jetpack Compose Testing codelab.