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.
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.
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:
androidTestImplementation("androidx.compose.ui:ui-test-junit4:$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()
}
}
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
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.onAllNodesWithLabel("Beatle").assertCountEquals(4)
// At least one matches
composeTestRule.onAllNodesWithLabel("Beatle").assertAny(hasTestTag("Drummer"))
// All of them match
composeTestRule.onAllNodesWithLabel("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.onNodeWithLabel("Continue").performClick()
// Set theme to dark
themeIsDark.value = true
// Check that we're still on the same page
composeTestRule.onNodeWithLabel("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()))
}