14.
UI Tests in Jetpack Compose
Written by Prateek Prasad
Congratulations on wrapping up the JetReddit app. Throughout the last thirteen chapters, you learned about the fundamentals of Jetpack Compose and built the JetReddit app from the ground up.
It’s time to verify the app’s functionality and learn how UI testing works in Jetpack Compose. In this chapter, you’ll learn how to write UI tests for your screens and components in Jetpack Compose and how assertions work. Let’s jump straight in!
Loading the Starter Project
To follow along with the code examples, open this chapter’s starter project in Android Studio and select Open an existing project.
Next, navigate to 14-ui-tests-in-compose/projects and select the starter folder as the project root. Once the project opens, let it build and sync, and you’re ready to go!
It’s the same app you’ve worked on, with a few structural changes to the code.
Open the JetRedditApp.kt file. Here, the ViewModel is no longer passed as a parameter to JetRedditApp composable. Instead, you now have separate state objects and event handlers to render the UI and pass events to the parent activity hosting the composable:
@Composable
fun JetRedditApp(
allPosts: List<PostModel>,
myPosts: List<PostModel>,
communities: List<String>,
selectedCommunity: String,
savePost: (post: PostModel) -> Unit,
searchCommunities: (searchedText: String) -> Unit,
communitySelected: (community: String) -> Unit,
) {
JetRedditTheme {
AppContent(
allPosts,
myPosts,
communities,
selectedCommunity,
savePost,
searchCommunities,
communitySelected
)
}
}
This slight change in structure will go a long way in helping you set up the test environment in the absence of a proper dependency injection setup.
The dependencies required for testing your composables have been added to the app’s build.gradle file as shown below:
// Compose testing dependencies
androidTestImplementation "androidx.compose.ui:ui-test:$compose_version"
androidTestImplementation "androidx.compose.
ui:ui-test-junit4:$compose_version"
debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version"
Before writing your first UI test, it’s good to understand how tests work in Jetpack Compose.
Note: Please ensure you use an emulator or device running a modern version of Android before running the tests in this chapter. Some older versions of Android may report failing tests.
Behind the Scenes of UI Tests in Jetpack Compose
UI tests for composables fall into the instrumentation test category, just like espresso tests, as you need a physical device or an emulator to run them.
There are a few essential aspects to note, however, in how testing works. In Jetpack Compose, your UI is represented as a tree of nodes. The parent is at the root node, and all children are further down the tree hierarchy. This tree contains all the visual information required to render the UI of your app.
The framework maintains an additional tree in addition to the UI tree that contains further information about each node. It’s called the Semantics tree, and as the name suggests, it describes each element’s ‘semantic meaning.’
Semantic properties include content description, text, actions assigned to the node and reference to the node’s parent and children (if any). It’s an alternate representation of a UI node used by the accessibility services and for finding and matching components in UI tests using matchers.
Now that you understand the theory behind tests in Jetpack Compose, it’s time to write your first one.
Testing UI Components in Jetpack Compose
Just like previewing and deploying individual components in Jetpack Compose, you can also write UI tests for them in isolation. Testing components in isolation allows you the ease and flexibility to set up a contained environment to test individual UI variations quickly.
You will first write tests for your Post composable.
Open the PostTest.kt file in the androidTest source directory and replace the first TODO comment with the following:
@get:Rule(order = 0)
val composeTestRule = createComposeRule()
Add following imports as well:
import androidx.compose.ui.test.junit4.createComposeRule
import org.junit.Rule
The createComposeRule() creates a test rule you will use to host your composable for testing and for performing assertions.
When writing tests for composables, there are three key ways of interacting with elements:
- Finders let you select one or multiple nodes in the Semantics tree to make assertions or perform actions on them.
- Assertions are used to verify that the elements exist or have the required attributes.
- Actions inject simulated user events into the elements, such as clicks or other gestures.
Update the title_is_displayed() method like below:
@Test
fun title_is_displayed() {
val post = PostModel.DEFAULT_POST
composeTestRule.setContent {
Post(post = post)
}
composeTestRule.onNodeWithText(post.title).assertIsDisplayed()
}
Add following imports as well:
import com.yourcompany.android.jetreddit.components.Post
import com.yourcompany.android.jetreddit.domain.model.PostModel
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithText
In the snippet above, you created a post object. You then used the rule created earlier to call setContent() and invoke the Post composable.
Finally, you used the rule to assert that a node with text containing the post title is displayed.
Run the test to see if it passes.
Next, you will write a test to assert that the like count of the post object is displayed.
Update the like_count_is_displayed() as shown below:
@Test
fun like_count_is_displayed() {
val post = PostModel.DEFAULT_POST
composeTestRule.setContent {
Post(post = post)
}
composeTestRule.onNodeWithText(post.likes).assertIsDisplayed()
}
The steps here are similar to the previous test, except you assert that the post’s like count is displayed in a node.
For your next test, you will assert that the image is shown for a post containing a valid image. But before writing the test, there’s an additional step required.
Open Post.kt file and add the testTag() modifier to the ImageContent composable as shown below:
modifier = Modifier
.fillMaxWidth()
.aspectRatio(painter.intrinsicSize.width / painter.intrinsicSize.height)
.testTag(Tags.POST_IMAGE)
Add the following imports as well:
import androidx.compose.ui.platform.testTag
import com.yourcompany.android.jetreddit.util.Tags
A test tag adds a helpful tag to a composable for it to be found from within tests. In this case, you are assigning a POST_IMAGE tag predefined in the Tags object in the util package of the project.
Now update the image_is_displayed_for_post_with_image() in the PostTest class as follows:
@Test
fun image_is_displayed_for_post_with_image() {
val post = PostModel.DEFAULT_POST
composeTestRule.setContent {
ImagePost(post = post)
}
composeTestRule.onNodeWithTag(Tags.POST_IMAGE, true).assertIsDisplayed()
}
Add the following imports:
import androidx.compose.ui.test.onNodeWithTag
import com.yourcompany.android.jetreddit.components.ImagePost
import com.yourcompany.android.jetreddit.util.Tags
In this test, you use the onNodeWithTag() finder method to find a node with the tag POST_IMAGE and then assert that it is being displayed.
Now that you have the basics of writing UI tests covered, try writing the test for text_is_displayed_for_post_with_text() yourself to practice. If you get stuck, you can find the solution in the final project.
Writing Tests for Screens
Having covered component-level tests, it’s now time to look at how UI testing works for screens.
Since screens are built by composing smaller components, the tests will look similar to what you have written.
Open the JetRedditAppTest.kt file and replace the first TODO comment with the following:
@get:Rule(order = 0)
val composeTestRule = createAndroidComposeRule(MainActivity::class.java)
Add the following imports:
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import org.junit.Rule
Here, instead of using createComposeRule() you are using createAndroidComposeRule in order to access the MainActivity.
Note: Access to the activity is required in this test file since you will be using string resources in the matchers, which need access to the context.
Next, update the app_shows_home_screen() with the following:
@Test
fun app_shows_home_screen() {
composeTestRule.activity.setContent { //1
JetRedditApp(
allPosts = PostDataFactory.createPosts(), //2
myPosts = PostDataFactory.createPosts(),
communities = PostDataFactory.createCommunities(),
selectedCommunity = PostDataFactory.randomString(),
savePost = {},
searchCommunities = {},
communitySelected ={}
)
}
//3
composeTestRule.onNodeWithText(
composeTestRule.activity.getString(R.string.home)
).assertIsDisplayed()
}
Before you proceed, import the necessary files:
import androidx.activity.compose.setContent
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithText
import com.yourcompany.android.jetreddit.factory.PostDataFactory
A lot is going on in the code above, so here’s a breakdown:
-
You are using the activity property of the rule to set the content to the
JetRedditApp()composable. -
The
JetRedditApp()composable receives dummy data from the PostDataFactory.kt file with a few utility functions for testing. -
The
onNodeWithText()matcher uses the String received from thegetString()function to assert that the home screen is displayed.
For the next test, you will verify that the subreddits screen is displayed when you click on the communities tab in the bottom app bar. But first, you need to add test tags to the bottom navigation items so you can find them in the test.
Open the JetRedditApp.kt file and in the BottomNavigationComponent() composable, add the modifier as shown below:
BottomNavigation(modifier = modifier) {
items.forEach {
BottomNavigationItem(
modifier = Modifier.testTag(it.screen.route),
....
)
}
}
Add the following imports as well:
import androidx.compose.ui.platform.testTag
You are using the route property of the screen as the test tag for each navigation item. Now update the app_shows_subreddits_screen() with the following:
@Test
fun app_shows_subreddits_screen() {
composeTestRule.activity.setContent { //1
JetRedditApp(
allPosts = PostDataFactory.createPosts(),
myPosts = PostDataFactory.createPosts(),
communities = PostDataFactory.createCommunities(),
selectedCommunity = PostDataFactory.randomString(),
savePost = {},
searchCommunities = {},
communitySelected ={}
)
}
composeTestRule.onNodeWithTag(
Screen.Subscriptions.route
).performClick() //2
composeTestRule.onNodeWithText(
composeTestRule.activity.getString(R.string.subreddits)
).assertIsDisplayed() //3
}
Add the following imports to clear off the red lines:
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import com.yourcompany.android.jetreddit.routing.Screen
Here’s a breakdown of what’s going on:
-
You set the
JetRedditAppas the composable for the rule’s activity. -
You use the
onNodeWithTag()finder to find the node with route label for the communities tab and use theperformClick()action to click it. -
You then assert that the communities tab is displayed.
Run the test to see if it passes.
Next, you will write a test to assert that the drawer is displayed when you click on the icon in the app bar.
First you need to add a tag to the app bar icon. Open the JetRedditApp.kt file and in the TopAppBar composable add the following test tag to the navigation icon as shown below:
navigationIcon = {
IconButton(
modifier = Modifier.testTag(Tags.ACCOUNT_BUTTON),
onClick = {
coroutineScope.launch { scaffoldState.drawerState.open() }
}) {
...
}
}
Add the following import statement:
import com.yourcompany.android.jetreddit.util.Tags
Next, update app_shows_drawer() in JetRedditAppTest.kt file as follows:
@Test
fun app_shows_drawer() {
composeTestRule.activity.setContent {
JetRedditApp(
allPosts = PostDataFactory.createPosts(),
myPosts = PostDataFactory.createPosts(),
communities = PostDataFactory.createCommunities(),
selectedCommunity = PostDataFactory.randomString(),
savePost = {},
searchCommunities = {},
communitySelected ={}
)
}
composeTestRule.onNodeWithTag(
Tags.ACCOUNT_BUTTON
).performClick()
composeTestRule.onNodeWithText(
composeTestRule.activity.getString(R.string.default_username)
).assertIsDisplayed()
}
Add the following import as well:
import com.yourcompany.android.jetreddit.util.Tags
Like the previous test, you find the node with the tag ACCOUNT_BUTTON, click it and then assert if the drawer is displayed. Run the test to confirm it passes.
For your next test, you will verify if a the message is displayed when you click on the join button in the post composable.
First, add the tags to the join button and the toast composable.
Open the JoinButton.kt file and add testTag(Tags.JOIN_BUTTON) to the end of the modifier chain of the enclosing Box:
@Composable
fun JoinButton(onClick: (Boolean) -> Unit = {}) {
...
...
Box(
modifier = Modifier
.clip(shape)
...
...
.testTag(Tags.JOIN_BUTTON) // add here
) {
...
...
}
}
Add the necessary import statement as well:
import androidx.compose.ui.platform.testTag
import com.yourcompany.android.jetreddit.util.Tags
Next, open the HomeScreen.kt file and add the test tag to the Box, wrapping the JoinedToast composable as shown below:
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 16.dp)
.testTag(Tags.JOINED_TOAST)
) {
JoinedToast(visible = isToastVisible)
}
Add the following imports before proceeding:
import androidx.compose.ui.platform.testTag
import com.yourcompany.android.jetreddit.util.Tags
Now, update the app_shows_toast_when_joining_community() in JetRedditAppTest.kt file as follows:
@Test
fun app_shows_toast_when_joining_community() {
composeTestRule.activity.setContent {
JetRedditApp(
allPosts = PostDataFactory.createPosts(),
myPosts = PostDataFactory.createPosts(),
communities = PostDataFactory.createCommunities(),
selectedCommunity = PostDataFactory.randomString(),
savePost = {},
searchCommunities = {},
communitySelected ={}
)
}
composeTestRule.onAllNodes(
hasTestTag(Tags.JOIN_BUTTON)
).onFirst().performClick()
composeTestRule.onNodeWithTag(Tags.JOINED_TOAST).assertIsDisplayed()
}
The test above is similar to previous ones except for one key difference. The home screens shows a list of posts, all of which have a join button.
In this test, you used the onAllNodes() finder and performed the click action on the first one using the onFirst() helper function.
Run the test to make sure it passes.
Before closing off the chapter you will take a look at how to write UI tests for hybrid screens that are built using a combination of Android Views and composables.
Writing Tests for Hybrid Screens
In a hybrid setup you will often find composables inside an XML view hierarchy and views inside composable trees.
This is going to be what most Android projects will look like for a few years until Compose becomes mainstream and the de-facto option for building user interfaces.
Good thing about writing tests for such situations is that you do not require any special setup. You will use Espresso for finding and matching Android Views and the ComposeTestRule for finding and matching your compose components.
In the JetReddit app you have a hybrid setup in two places:
- The
TrendingItemin the Home screen which is an Android View inside a composable tree. - The
ComposeButtoninsideChatActivitywhich is a composable inside an android view based tree hierarchy.
You will write a few basic tests for both of these components.
First up, the ComposeButton.
Open the JetRedditApp.kt file and in the TopAppBar() composable, add the modifier as shown below in the IconButton:
if (screen == Screen.Home) {
IconButton(
modifier = Modifier.testTag(Tags.CHAT_BUTTON), // add here
onClick = {
context.startActivity(
Intent(context, ChatActivity::class.java)
)
}) {
...
}
}
Next, open the JetRedditAppTest.kt file and update the chat_button_is_displayed() as follows:
@Test
fun chat_button_is_displayed() {
//1
composeTestRule.onNodeWithTag(
Tags.CHAT_BUTTON
).performClick()
//2
Espresso.onView(withId(R.id.composeButton))
.check(matches(isDisplayed()))
}
Add the following imports as well:
import androidx.test.espresso.Espresso
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
Here’s a breakdown of what’s going on in the snippet above:
-
You used the
composeTestRuleto find the node with the tagCHAT_BUTTONand used theperformClick()action to click it -
You then used the regular Espresso
withId()matcher to find the button with idR.id.composeButtonand assert that it was displayed.
As you can see, there’s nothing special about this test aside from the fact that you use both Espresso and the ComposeTestRule to perform the assertion.
Run the test to confirm it passes.
For your final test, you will assert that the TrendingItem is displayed correctly on the screen.
Open the TrendingItemTest.kt file and create the test rule as follows:
@get:Rule
val composeTestRule = createComposeRule()
Next, update the trending_item_is_displayed() method as follows:
@Test
fun trending_item_is_displayed() {
val topic = TrendingTopicModel(
"Compose Tutorial",
R.drawable.jetpack_composer
)
composeTestRule.setContent {
TrendingTopic(topic)
}
composeTestRule.onNodeWithTag(Tags.TRENDING_ITEM)
.assertIsDisplayed()
}
Add the following imports before proceeding:
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import com.yourcompany.android.jetreddit.util.Tags
import org.junit.Rule
import com.yourcompany.android.jetreddit.screens.TrendingTopic
import com.yourcompany.android.jetreddit.screens.TrendingTopicModel
Finally, open HomeScreen.kt and update the TrendingTopic composable like so:
AndroidView(modifier = Modifier.testTag(Tags.TRENDING_ITEM), factory = { context ->
TrendingTopicView(context).apply {
text = trendingTopic.text
image = trendingTopic.imageRes
}
})
Here, you add a test tag to the AndroidView so it can found in the UI tests.
With the setup done, you can see the process is similar to previous tests that you wrote in this chapter. You created an instance of the TrendingTopicModel and set it as the content of the test rule. You then used the onNodeWithTag() finder, to find the item and assert it was displayed.
Finally, run the test to confirm it passes.
With this final test wrapped up, you now have all the skills needed to write tests for most scenarios and UI variants you can think of. Use the learnings from the previous tests to finish things off with the app_shows_new_post_screen() on your own.
If you need help, refer to the solution in the final project.
For a list of all available finders and actions, visit the compose testing cheatsheet.
Key Points
-
UI tests for composables fall under the instrumentation test category.
-
The compose runtime maintains an alternate representation of the UI tree called Semantics tree.
-
You can use Finder functions to find specific nodes of the tree based on criteria like text, tags or actions
-
You can use Action functions to interact with specific UI elements in your tests.