1. Introduction and setup
In this codelab you'll learn about testing UIs created with Jetpack Compose. You will write your first tests while learning about testing in isolation, debugging tests, semantics trees and synchronization.
What you'll need
- Latest Android Studio
- Knowledge of Kotlin
- Basic understanding of Compose (such as the
@Composable
annotation) - Basic familiarity with modifiers
- Optional: Consider taking the Jetpack Compose basics codelab before this codelab
Check out the code for this codelab (Rally)
You're going to use the Rally Material study as the base for this codelab. You'll find it in the android-compose-codelabs Github repository. To clone, run:
git clone https://github.com/android/codelab-android-compose.git
Once downloaded, open the TestingCodelab
project.
Alternatively, you can download two zip files:
Open the TestingCodelab folder, which contains an app called Rally.
Examine the project structure
Compose tests are instrumented tests. This means they require a device (physical device or emulator) to run on.
Rally already contains some Instrumented UI tests. You can find them in the androidTest source set:
This is the directory where you'll place the new tests. Feel free to take a look at the AnimatingCircleTests.kt
file to learn what a Compose test looks like.
Rally is already configured, but all you need to enable Compose tests in a new project is the testing dependencies in the build.gradle
file of the relevant module, which are:
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$version"
debugImplementation "androidx.compose.ui:ui-test-manifest:$rootProject.composeVersion"
Feel free to run the app and familiarize yourself with it.
2. What to test?
We're going to focus on Rally's tab bar, which contains a row of tabs (Overview, Accounts, and Bills). It looks like this in context:
In this codelab you'll test the UI of the bar.
This could mean many things:
- Test that the tabs show the intended icon and text
- Test that the animation matches the spec
- Test that the triggered navigation events are correct
- Test the placement and distances of the UI elements in different states
- Take a screenshot of the bar and compare it with a previous screenshot
There are no exact rules about how much or how to test a component. You could do all of the above! In this codelab you're going to test that the state logic is correct by verifying that:
- A tab shows its label only when it is selected.
- The active screen defines the tab that is selected
3. Create a simple UI test
Create the TopAppBarTest file
Create a new file in the same folder as AnimatingCircleTests.kt
(app/src/androidTest/com/example/compose/rally
) and call it TopAppBarTest.kt
.
Compose comes with a ComposeTestRule
that you can obtain by calling createComposeRule()
. This rule lets you set the Compose content under test and interact with it.
Add the ComposeTestRule
package com.example.compose.rally
import androidx.compose.ui.test.junit4.createComposeRule
import org.junit.Rule
class TopAppBarTest {
@get:Rule
val composeTestRule = createComposeRule()
// TODO: Add tests
}
Testing in isolation
In a Compose test, we can start the app's main activity similarly to how you would do it in the Android View world using Espresso, for example. You can do this with createAndroidComposeRule
.
// Don't copy this over
@get:Rule
val composeTestRule = createAndroidComposeRule(RallyActivity::class.java)
However, with Compose, we can simplify things considerably by testing a component in isolation. You can choose what Compose UI content to use in the test. This is done with the setContent
method of the ComposeTestRule
, and you can call it anywhere (but just once).
// Don't copy this over
class TopAppBarTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun myTest() {
composeTestRule.setContent {
Text("You can set any Compose content!")
}
}
}
We want to test the TopAppBar, so let's focus on that. Call RallyTopAppBar
inside setContent
and let Android Studio complete the names of the parameters.
import androidx.compose.ui.test.junit4.createComposeRule
import com.example.compose.rally.ui.components.RallyTopAppBar
import org.junit.Rule
import org.junit.Test
class TopAppBarTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun rallyTopAppBarTest() {
composeTestRule.setContent {
RallyTopAppBar(
allScreens = ,
onTabSelected = { /*TODO*/ },
currentScreen =
)
}
}
}
The importance of a testable Composable
RallyTopAppBar
takes three parameters that are easy to provide so we can pass fake data that we control. For example:
@Test
fun rallyTopAppBarTest() {
val allScreens = RallyScreen.values().toList()
composeTestRule.setContent {
RallyTopAppBar(
allScreens = allScreens,
onTabSelected = { },
currentScreen = RallyScreen.Accounts
)
}
Thread.sleep(5000)
}
We also add a sleep()
so you can see what's going on. Right-click on the rallyTopAppBarTest
and click on "Run rallyTopAppBarTest()...".
The test shows the top app bar (for 5 seconds), but it doesn't look as we expected: it has a light theme!
The reason is that the bar is built using Material Components, which expect to be within a MaterialTheme, else they fall back to "baseline" styles colors.
MaterialTheme
has good defaults so it doesn't crash. Since we're not going to test the theme or take screenshots, we can omit it and work with its default light theme. Feel free to wrap RallyTopAppBar
with RallyTheme
to fix it.
Verify that the tab is selected
Finding UI elements, checking their properties and performing actions is done through the test rule, following this pattern:
composeTestRule{.finder}{.assertion}{.action}
In this test you will look for the word "Accounts" to verify that the label for the selected tab is shown.
A good way to understand what tools are available is using the Compose Testing Cheat Sheet or the test package reference documentation. Look for finders and assertions that might help in our situation. For example: onNodeWithText
, onNodeWithContentDescription
, isSelected
, hasContentDescription
, assertIsSelected
...
Each tab has a different content description:
- Overview
- Accounts
- Bills
Knowing this, replace the Thread.sleep(5000)
with a statement that looks for a content description and asserts that it exists:
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.onNodeWithContentDescription
...
@Test
fun rallyTopAppBarTest_currentTabSelected() {
val allScreens = RallyScreen.values().toList()
composeTestRule.setContent {
RallyTopAppBar(
allScreens = allScreens,
onTabSelected = { },
currentScreen = RallyScreen.Accounts
)
}
composeTestRule
.onNodeWithContentDescription(RallyScreen.Accounts.name)
.assertIsSelected()
}
Now run the test again and you should see a green test:
Congratulations! You've written your first Compose test. You've learned how to test in isolation, and how to use finders and assertions.
This was straightforward, but it required some previous knowledge about the component (the content descriptions and the selected property). You'll learn how to inspect what properties are available in the next step.
4. Debugging tests
In this step you'll verify that the label of the current tab is displayed, in upper case.
A possible solution would be to try to find the text and assert that it exists:
import androidx.compose.ui.test.onNodeWithText
...
@Test
fun rallyTopAppBarTest_currentLabelExists() {
val allScreens = RallyScreen.values().toList()
composeTestRule.setContent {
RallyTopAppBar(
allScreens = allScreens,
onTabSelected = { },
currentScreen = RallyScreen.Accounts
)
}
composeTestRule
.onNodeWithText(RallyScreen.Accounts.name.uppercase())
.assertExists()
}
However, if you run the test, it fails 😱
In this step you'll learn how to debug this using the semantics tree.
Semantics tree
Compose tests use a structure called the semantics tree to look for elements on the screen and read their properties. This is the structure that accessibility services use as well, as they're meant to be read by a service such as TalkBack.
You can print the Semantics tree using the printToLog
function on a node. Add a new line to the test:
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.printToLog
...
fun rallyTopAppBarTest_currentLabelExists() {
val allScreens = RallyScreen.values().toList()
composeTestRule.setContent {
RallyTopAppBar(
allScreens = allScreens,
onTabSelected = { },
currentScreen = RallyScreen.Accounts
)
}
composeTestRule.onRoot().printToLog("currentLabelExists")
composeTestRule
.onNodeWithText(RallyScreen.Accounts.name.uppercase())
.assertExists() // Still fails
}
Now run the test and check out the Logcat in Android Studio (you can look for currentLabelExists
).
...com.example.compose.rally D/currentLabelExists: printToLog:
Printing with useUnmergedTree = 'false'
Node #1 at (l=0.0, t=63.0, r=1080.0, b=210.0)px
|-Node #2 at (l=0.0, t=63.0, r=1080.0, b=210.0)px
[SelectableGroup]
MergeDescendants = 'true'
|-Node #3 at (l=42.0, t=105.0, r=105.0, b=168.0)px
| Role = 'Tab'
| Selected = 'false'
| StateDescription = 'Not selected'
| ContentDescription = 'Overview'
| Actions = [OnClick]
| MergeDescendants = 'true'
| ClearAndSetSemantics = 'true'
|-Node #6 at (l=189.0, t=105.0, r=468.0, b=168.0)px
| Role = 'Tab'
| Selected = 'true'
| StateDescription = 'Selected'
| ContentDescription = 'Accounts'
| Actions = [OnClick]
| MergeDescendants = 'true'
| ClearAndSetSemantics = 'true'
|-Node #11 at (l=552.0, t=105.0, r=615.0, b=168.0)px
Role = 'Tab'
Selected = 'false'
StateDescription = 'Not selected'
ContentDescription = 'Bills'
Actions = [OnClick]
MergeDescendants = 'true'
ClearAndSetSemantics = 'true'
Taking a look at the Semantics tree, you can see there is a SelectableGroup
with 3 child elements, which are the tabs of the top app bar. Turns out, there is no text
property with a value of "ACCOUNTS" and this is why the test fails. However, there is a content description for each tab. You can check how this property is set in the RallyTab
composable inside RallyTopAppBar.kt
:
private fun RallyTab(text: String...)
...
Modifier
.clearAndSetSemantics { contentDescription = text }
This modifier is clearing the properties from descendants and setting its own content description, so that's why you see "Accounts" and not "ACCOUNTS".
Replace the finder onNodeWithText
with onNodeWithContentDescription
and run the test again:
fun rallyTopAppBarTest_currentLabelExists() {
val allScreens = RallyScreen.values().toList()
composeTestRule.setContent {
RallyTopAppBar(
allScreens = allScreens,
onTabSelected = { },
currentScreen = RallyScreen.Accounts
)
}
composeTestRule
.onNodeWithContentDescription(RallyScreen.Accounts.name)
.assertExists()
}
Congratulations! You've fixed the test and you learned about the ComposeTestRule
, testing in isolation, finders, assertions, and debugging with the Semantics tree.
Bad news though: this test is not very useful! If you look at the Semantics tree closely, the content descriptions of all three tabs are there whether or not their tab is selected. We must go deeper!
5. Merged and unmerged Semantics trees
The Semantics tree always tries to be as compact as possible, showing only the information that is relevant.
For example, in our TopAppBar
, there is no need for the icons and labels to be different nodes. Take a look at the "Overview" node:
|-Node #3 at (l=42.0, t=105.0, r=105.0, b=168.0)px
| Role = 'Tab'
| Selected = 'false'
| StateDescription = 'Not selected'
| ContentDescription = 'Overview'
| Actions = [OnClick]
| MergeDescendants = 'true'
| ClearAndSetSemantics = 'true'
This node has properties (such as Selected
and Role
) that are defined specifically for a selectable
component and a content description for the whole tab. These are high-level properties, very useful for simple tests. Details about the icon or the text would be redundant so they're not shown.
Compose exposes these Semantics properties automatically in some composables such as Text
. You can also customize and merge them to represent a single component made up of one or multiple descendants. For example: you can represent a Button
containing a Text
composable. The property MergeDescendants = 'true'
is telling us that this node had descendants, but they have been merged into it. In tests we oftentimes need to access all nodes.
In order to verify whether the Text
inside the tab is displayed or not, we can query the unmerged Semantics tree passing useUnmergedTree = true
to the onRoot
finder.
@Test
fun rallyTopAppBarTest_currentLabelExists() {
val allScreens = RallyScreen.values().toList()
composeTestRule.setContent {
RallyTopAppBar(
allScreens = allScreens,
onTabSelected = { },
currentScreen = RallyScreen.Accounts
)
}
composeTestRule.onRoot(useUnmergedTree = true).printToLog("currentLabelExists")
}
The output in Logcat is slightly longer now:
Printing with useUnmergedTree = 'true'
Node #1 at (l=0.0, t=63.0, r=1080.0, b=210.0)px
|-Node #2 at (l=0.0, t=63.0, r=1080.0, b=210.0)px
[SelectableGroup]
MergeDescendants = 'true'
|-Node #3 at (l=42.0, t=105.0, r=105.0, b=168.0)px
| Role = 'Tab'
| Selected = 'false'
| StateDescription = 'Not selected'
| ContentDescription = 'Overview'
| Actions = [OnClick]
| MergeDescendants = 'true'
| ClearAndSetSemantics = 'true'
|-Node #6 at (l=189.0, t=105.0, r=468.0, b=168.0)px
| Role = 'Tab'
| Selected = 'true'
| StateDescription = 'Selected'
| ContentDescription = 'Accounts'
| Actions = [OnClick]
| MergeDescendants = 'true'
| ClearAndSetSemantics = 'true'
| |-Node #9 at (l=284.0, t=105.0, r=468.0, b=154.0)px
| Text = 'ACCOUNTS'
| Actions = [GetTextLayoutResult]
|-Node #11 at (l=552.0, t=105.0, r=615.0, b=168.0)px
Role = 'Tab'
Selected = 'false'
StateDescription = 'Not selected'
ContentDescription = 'Bills'
Actions = [OnClick]
MergeDescendants = 'true'
ClearAndSetSemantics = 'true'
Node #3 still has no descendants:
|-Node #3 at (l=42.0, t=105.0, r=105.0, b=168.0)px
| Role = 'Tab'
| Selected = 'false'
| StateDescription = 'Not selected'
| ContentDescription = 'Overview'
| Actions = [OnClick]
| MergeDescendants = 'true'
| ClearAndSetSemantics = 'true'
But node 6, the selected tab, has one and we can now see the ‘Text' property:
|-Node #6 at (l=189.0, t=105.0, r=468.0, b=168.0)px
| Role = 'Tab'
| Selected = 'true'
| StateDescription = 'Selected'
| ContentDescription = 'Accounts'
| Actions = [OnClick]
| MergeDescendants = 'true'
| |-Node #9 at (l=284.0, t=105.0, r=468.0, b=154.0)px
| Text = 'ACCOUNTS'
| Actions = [GetTextLayoutResult]
In order to verify the correct behavior as we wanted, you'll write a matcher that finds one node with text "ACCOUNTS" whose parent is a node with content description "Accounts".
Check the Compose Testing Cheat Sheet again and try to find a way to write that matcher. Note that you can use boolean operators such as and
and or
with matchers.
All finders have a parameter called useUnmergedTree
. Set it to true
to use the unmerged tree.
Try to write the test without looking at the solution!
Solution
import androidx.compose.ui.test.hasParent
import androidx.compose.ui.test.hasText
...
@Test
fun rallyTopAppBarTest_currentLabelExists() {
val allScreens = RallyScreen.values().toList()
composeTestRule.setContent {
RallyTopAppBar(
allScreens = allScreens,
onTabSelected = { },
currentScreen = RallyScreen.Accounts
)
}
composeTestRule
.onNode(
hasText(RallyScreen.Accounts.name.uppercase()) and
hasParent(
hasContentDescription(RallyScreen.Accounts.name)
),
useUnmergedTree = true
)
.assertExists()
}
Go ahead and run it:
Congratulations! In this step you've learned about property merging and the merged and unmerged Semantics trees.
6. Synchronization
Any test that you write must be properly synchronized with the subject under test. For example, when you use a finder such as onNodeWithText
, the test waits until the app is idle before querying the semantics tree. Without synchronization, tests could look for elements before they're displayed or they could wait unnecessarily.
We'll use the Overview screen for this step, which looks like this when you run the app:
Note the repeating flashing animation of the Alerts card, drawing attention to this element.
Create another test class called OverviewScreenTest
and add the following content:
package com.example.compose.rally
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import com.example.compose.rally.ui.overview.OverviewBody
import org.junit.Rule
import org.junit.Test
class OverviewScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun overviewScreen_alertsDisplayed() {
composeTestRule.setContent {
OverviewBody()
}
composeTestRule
.onNodeWithText("Alerts")
.assertIsDisplayed()
}
}
If you run this test, you'll notice it never finishes (it times out after 30 seconds).
The error says:
androidx.compose.ui.test.junit4.android.ComposeNotIdleException: Idling resource timed out: possibly due to compose being busy.
IdlingResourceRegistry has the following idling resources registered:
- [busy] androidx.compose.ui.test.junit4.android.ComposeIdlingResource@d075f91
This is basically telling you that Compose is permanently busy so there is no way to synchronize the app with the test.
You might already have guessed that the problem is the infinite flashing animation. The app is never idle so the test can't continue.
Let's look at the implementation of the infinite animation:
app/src/main/java/com/example/compose/rally/ui/overview/OverviewBody.kt
var currentTargetElevation by remember { mutableStateOf(1.dp) }
LaunchedEffect(Unit) {
// Start the animation
currentTargetElevation = 8.dp
}
val animatedElevation = animateDpAsState(
targetValue = currentTargetElevation,
animationSpec = tween(durationMillis = 500),
finishedListener = {
currentTargetElevation = if (currentTargetElevation > 4.dp) {
1.dp
} else {
8.dp
}
}
)
Card(elevation = animatedElevation.value) { ... }
This code is essentially waiting for an animation to finish (finishedListener
) and then runs it again.
One approach to fix this test would be to disable animations in developer options. It's one of the widely accepted ways of dealing with it in the View
world.
In Compose, the animation APIs were designed with testability in mind, so the problem can be fixed by using the correct API. Instead of restarting the animateDpAsState
animation, we can use infinite animations.
Replace the code in OverviewScreen
with the proper API:
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.animateValue
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.ui.unit.Dp
...
val infiniteElevationAnimation = rememberInfiniteTransition()
val animatedElevation: Dp by infiniteElevationAnimation.animateValue(
initialValue = 1.dp,
targetValue = 8.dp,
typeConverter = Dp.VectorConverter,
animationSpec = infiniteRepeatable(
animation = tween(500),
repeatMode = RepeatMode.Reverse
)
)
Card(elevation = animatedElevation) {
If you run the test, it will pass now:
Congratulations! In this step you've learned about synchronization and how animations can affect tests.
7. Optional exercise
In this step, you'll use an action (see the Testing Cheat Sheet) to verify that clicking on the different tabs of the RallyTopAppBar
changes the selection.
Hints:
- The scope of the test needs to include the state, which is owned by
RallyApp
. - Verify state, not behavior. Use assertions on the state of the UI instead of relying on which objects have been called and how.
There's no provided solution for this exercise.
8. Next steps
Congratulations! You've completed the Testing in Jetpack Compose. Now you have the basic building blocks to create a good testing strategy for your Compose UIs.
If you want to learn more about Testing and Compose, check out these resources:
- The testing documentation has more information about finders, assertions, actions and matchers, as well as synchronization mechanisms, time manipulation, etc.
- Bookmark the Testing Cheat Sheet!
- The Rally sample comes with a simple screenshot test class. Explore the
AnimatingCircleTests.kt
file to learn more about it. - For general guidance about testing Android apps, you can follow these three codelabs:
- The Compose samples repository on Github has multiple apps with UI tests.
- The Jetpack Compose Pathway shows a list of resources to get you started with Compose.
Happy testing!