Todo App Series: Elevating Android UI Testing with Jetpack Compose
Introduction
Welcome to our series on crafting a functional todo app using Android Compose. Whether you’re joining us for the first time or continuing from our previous sessions, this tutorial is designed to be accessible to all. We provide all the necessary materials and project files, so you can jump right in or seamlessly pick up where you left off.
👀 Previous Session Recap: Last time, we focused on integrating Room DB to ensure robust data persistence, a key feature for maintaining the app state during unexpected closures and system interruptions.
🎯 Today’s Objective: With our todo application operational, the next crucial step is introducing UI tests. This phase is essential for verifying the app’s functionality, ensuring our app works effectively and is reliable.
Let’s dive into UI testing, a pivotal element in our app development process.
Environment
- Android Studio Hedgehog | 2023.1.1 Patch 2
- Compose version:
androidx.compose:compose-bom:2023.08.00
- Pixel 6 Emulator API 30
Step 1: Preparations
To ensure everyone is on the same page if you haven’t already set up the project environment, we’ve got you covered. Download the project here to get started. This step is crucial for both newcomers and those who’ve been following along but might not have the latest version of our project.
Once the project is downloaded, please add ui/constants/TestTags.kt
with the following content.
// Your package...
const val TODO_INPUT_BAR_INPUT_FIELD_TEST_TAG = "todo_input_bar_input_field_test_tag"
const val TODO_INPUT_BAR_ADD_TODO_FAB_TEST_TAG = "todo_input_bar_add_todo_fab_test_tag"
const val TODO_ITEM_UI_DELETE_BUTTON_TEST_TAG = "todo_item_ui_delete_button_test_tag"
const val TODO_ITEM_UI_CONTAINER_TEST_TAG = "todo_item_ui_container_test_tag"
const val TODO_ITEM_UI_CONTAINER_CHECKED_CONTENT_DESCRIPTION = "checked"
const val TODO_ITEM_UI_CONTAINER_UNCHECKED_CONTENT_DESCRIPTION = "unchecked"
In Android Compose, utilizing test tags and content descriptions is a strategic approach to pinpoint specific elements in UI tests.
Step 2: UI Test Implementation
Navigate to the composables/TodoInputBar.kt
file and integrate the test tags we've recently established for UI testing. Applying these tags will facilitate the identification of UI components during the testing phase.
Find the TextField()
used for the text field for the todo items, and apply the testTag()
modifier.
...
import androidx.compose.ui.platform.testTag
...
TextField(
modifier = Modifier
.weight(1f)
// Test Tag
.testTag(TODO_INPUT_BAR_INPUT_FIELD_TEST_TAG),
...
)
In the same file, locate the FloatingActionButton()
and include the test tag.
FloatingActionButton(
...
modifier = Modifier
.size(TodoInputBarFabSize)
// Test Tag
.testTag(TODO_INPUT_BAR_ADD_TODO_FAB_TEST_TAG),
...
) {
...
Test Tag
The testTag()
modifier attaches a test tag to a composable. Test tags serve as stable and unique identifiers that aid in locating the composable during UI testing.
After setting up your UI components with test tags, proceed to the androidTest
directory to incorporate a new testing file. Create the MainActivityTest.kt
inside.
Time to write our first test, updateMainActivityTest.kt
as follows.
// Your Package
// All needed imports for the rest of the tutorial
import androidx.compose.ui.test.assertContentDescriptionEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.test.core.app.ActivityScenario
// import YOUR_PACKAGE_NAME.ui.constants.TODO_INPUT_BAR_ADD_TODO_FAB_TEST_TAG
// import YOUR_PACKAGE_NAME.ui.constants.TODO_INPUT_BAR_INPUT_FIELD_TEST_TAG
// import YOUR_PACKAGE_NAME.ui.constants.TODO_ITEM_UI_CONTAINER_CHECKED_CONTENT_DESCRIPTION
// import YOUR_PACKAGE_NAME.ui.constants.TODO_ITEM_UI_CONTAINER_TEST_TAG
// import YOUR_PACKAGE_NAME.ui.constants.TODO_ITEM_UI_CONTAINER_UNCHECKED_CONTENT_DESCRIPTION
// import YOUR_PACKAGE_NAME.ui.constants.TODO_ITEM_UI_DELETE_BUTTON_TEST_TAG
import org.junit.Rule
import org.junit.Test
class MainActivityTest {
// 1. Compose Rule Creation
@get:Rule
val composeTestRule = createComposeRule()
// 2. Test Annotation
@Test
fun shouldDisplayAddedTodoItem() {
val todoText = "Walk the dog on the moon"
// 3. Activity Scenario
ActivityScenario.launch(MainActivity::class.java).use {
addTodoItem(todoText)
// 5. Android Compose Testing APIs
composeTestRule.onNodeWithText(todoText).assertIsDisplayed()
}
}
// 4. Add Todo Item Simplification
private fun addTodoItem(todoText: String) {
composeTestRule.onNodeWithTag(TODO_INPUT_BAR_INPUT_FIELD_TEST_TAG).performTextInput(todoText)
composeTestRule.onNodeWithTag(TODO_INPUT_BAR_ADD_TODO_FAB_TEST_TAG).performClick()
}
}
1. Compose Rule Creation
The composeTestRule
uses @get:Rule
, a Kotlin syntax for applying JUnit's @Rule
annotation. This rule establishes a controlled testing environment for Compose UI components, allowing for interaction with and manipulation of the UI during tests. The createComposeRule()
function is crucial as it isolates the UI components, ensuring the tests run in a stable and predictable environment.
2. Test Annotation
The @Test
annotation designates the shouldDisplayAddedTodoItem()
method as a test case. This annotation is integral to JUnit, signaling which methods should be executed as tests. Each test method is run independently, allowing for isolated verification of specific functionality or use cases.
3. Activity Scenario
ActivityScenario.launch(MainActivity::class.java)
is used to launch and control the lifecycle of the MainActivity.kt
during tests. It is vital to ensure that the UI components are tested within the context of a real activity, providing a more accurate representation of user interaction. The .use {...}
block in Kotlin ensures the activity is closed after execution to prevent resource leaks, maintaining a clean testing environment.
4. Add Todo Item Simplification
The function addTodoItem()
was designed to streamline the addition of a todo item in our MainActivityTest.kt
. This function enhances the readability and maintainability of our test code by encapsulating the steps required to add a todo item into a single, reusable method.
onNodeWithTag()
: Locates a UI element in the Compose hierarchy using a specified test tag. In our case,onNodeWithTag(TODO_INPUT_BAR_INPUT_FIELD_TEST_TAG)
finds theTextField()
tagged withTODO_INPUT_BAR_INPUT_FIELD_TEST_TAG
.performTextInput()
: Simulates typing into a text field. InperformTextInput(todoText)
, it inputs thetodoText
into the locatedTextField()
. It's essential for testing user input scenarios.performClick()
: Simulates a click action on a UI element. The linecomposeTestRule.onNodeWithTag(...).performClick()
simulates a user clicking theFloatingActionButton()
identified byTODO_INPUT_BAR_ADD_TODO_FAB_TEST_TAG
.
These APIs combined allow for precise targeting and interaction with UI components, mimicking real user actions in a controlled test environment.
5. Android Compose Testing APIs
There are several APIs specifically designed for testing purposes. The primary job is identifying an element and asserting its state, such as text content, visibility, etc.
onNodeWithText(todoText)
: This function locates a UI element (node) containing the specified text, in this case, an element displaying todoText
’s content. It is a key component of the Compose Testing Library, ideal for finding and interacting with text elements in the UI.
assertIsDisplayed()
: Once the node with the specified text is identified, this assertion method checks if the UI element is visible on the screen. If the element isn’t visible, the test will fail.
Running the Test
To execute a particular test, click the play button next to the @Test
function’s declaration.
Upon activation of the added test, a new todo item is added, followed by a verification that the input text appears on the screen, confirming the item's successful addition.
shouldDisplayAddedTodo()
Following the test run, you can expect the success result displayed in the test runner window. The outcome will be clear and concise, indicating whether the test passed or failed, along with any pertinent details.
Step 3: Dealing with List Content in UI Testing
Often, we have to deal with UI lists or dynamic content when designing tests. In such cases, Android Compose offers several options. One approach is to create dynamic test tags by combining an item’s unique identifier (like an itemId
) with a specific test tag. However, this method requires replicating the same tag combination in the UI test, which adds an extra layer of complexity. To overcome this, a more efficient strategy can be employed. By using the onAllNodesWithTag()
API, we can retrieve all nodes (UI components) and access a specific element’s position, which will be crucial for our next test.
In our upcoming UI test, we will feature toggling the state of a todo item. This involves a three-step process: add a new item to the list, toggle the added item, and finally confirm that its state has been successfully toggled.
First, locate the Row()
composable inside the composables/TodoItemUi.kt
that represents the entire surface of a todo item in your UI. This is crucial because it's the element with which users will interact to toggle the state of a todo item.
...
import androidx.compose.ui.platform.testTag
...
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
...
Row(
modifier = Modifier
// Test Tag
.testTag(TODO_ITEM_UI_CONTAINER_TEST_TAG)
// Semantics Block
.semantics {
contentDescription =
if (todoItem.isDone) TODO_ITEM_UI_CONTAINER_CHECKED_CONTENT_DESCRIPTION
else TODO_ITEM_UI_CONTAINER_UNCHECKED_CONTENT_DESCRIPTION
}
...
)
Semantics Block
The semantics{...}
block is employed to dynamically set a content description for the todo item, based on its isDone
state. This dynamic content description serves two purposes: it indicates the completion status of the todo item in a testing environment and provides an accessible description for a broader audience.
Let’s update the MainActivityTest.kt
and add the shouldToggleTodoItemState()
test and a private method toggleTodoItemState()
.
...
class MainActivityTest {
...
@Test
fun shouldToggleTodoItemState() {
val todoText = "Teach the cat quantum physics"
val todoItem1Position = 0
ActivityScenario.launch(MainActivity::class.java).use {
addTodoItem(todoText)
toggleTodoItemState(todoItem1Position)
composeTestRule
// 2. Assert State through Content Description
.onNodeWithTag(TODO_ITEM_UI_CONTAINER_TEST_TAG)
.assertContentDescriptionEquals(TODO_ITEM_UI_CONTAINER_CHECKED_CONTENT_DESCRIPTION)
}
}
...
// 1. Toggle Item State Simplification
private fun toggleTodoItemState(index: Int) {
composeTestRule.onAllNodesWithTag(TODO_ITEM_UI_CONTAINER_TEST_TAG)[index].performClick()
}
}
1. Toggle Item State Simplification
The toggleTodoItemState()
function is a key element in our MainActivityTest.kt
, offering a streamlined approach to changing the state of a todo item. It acts like a specialized tool in our testing suite, similar to a Domain-Specific Language (DSL), making test script writing more intuitive and efficient. Using the .onAllNodesWithTag(...)[index]
syntax, it targets and manipulates a specific item in a list based on its index. This is particularly effective for testing dynamic lists where specific actions, like toggling the state of a todo item, are required.
2. Assert State through Content Description
In our test scenario, after an item is added and its state is toggled, we must confirm that it’s marked as done (checked). We utilize a dynamic content description to achieve this. This approach involves using onNodeWithTag(...)
to locate the item with the specific test tag, and then applying assertContentDescriptionEquals(...)
to verify the content description matches our expectations for a checked item. This chain of methods effectively ensures that the item's state change to checked/unchecked
is accurately reflected in the UI, providing a reliable means to validate the toggling functionality.
Running the Test
When the shouldToggleTodoItemState()
test is executed, we use the composeTestRule
in three stages. Firstly, when addTodoItem()
is called to add a new item. Secondly, toggleTodoItemState()
is invoked to change the state of the added todo item. Finally, we verify the item's updated state by checking if it has the TODO_ITEM_UI_CONTAINER_TEST_TAG
and TODO_ITEM_UI_CONTAINER_CHECKED_CONTENT_DESCRIPTION
tags. This ensures that the item's state has been correctly updated upon interaction.
shouldToggleTodoItemState()
Step 4: Asserting Item Deletion
In this step, we focus on the action of deleting a todo item from the list. This operation involves not just the UI interaction but also ensuring that the item is effectively removed from the UI, reflecting the expected behavior of our app.
Firstly, update the TodoItemUi.kt
file to include the test tag for the delete button. This ensures that we can target the delete button during testing.
...
IconButton(
onClick = { onItemDelete(todoItem) },
modifier = Modifier
.size(TodoItemActionButtonRippleRadius)
.testTag(TODO_ITEM_UI_DELETE_BUTTON_TEST_TAG)
) {
...
Next, we expand our MainActivityTest.kt
file to include another utility function and a test case for item deletion.
...
class MainActivityTest {
...
// 2. Remove Test
@Test
fun shouldRemoveTodoItemWhenDeleted() {
val todoText = "Convince a kangaroo to do yoga"
val todoItem1Position = 0
ActivityScenario.launch(MainActivity::class.java).use {
addTodoItem(todoText)
deleteTodoItem(todoItem1Position)
composeTestRule.onNodeWithText(todoText).assertIsNotDisplayed()
}
}
...
// 1. Delete Action Simplification
private fun deleteTodoItem(index: Int) {
composeTestRule.onAllNodesWithTag(TODO_ITEM_UI_DELETE_BUTTON_TEST_TAG)[index].performClick()
}
}
1. Delete Action Simplication
As we saw in the previous step, given the dynamic nature of a todo list, where multiple items can exist, it’s crucial to employ the onAllNodesWithTag(…)[index]
method for precise targeting of specific items. This approach allows us to interact with the delete button of a specific item, identified by its index in the list.
2. Remove Test
The function assertIsNotDisplayed()
plays a crucial role. When applied in this context, it checks that the todo item, identified by its unique text, is no longer displayed in the user interface following the delete action. This check is essential to confirm that the app's deletion functionality is working correctly. If the item is still displayed, the test will fail, indicating an issue in the deletion process.
Running the Test
Upon successful execution, the test will verify the delete functionality, ensuring that when an item is removed by the user, it is indeed no longer present in the app’s UI. This final step ensures the reliability of the todo app’s deletion feature.
Step 5: Multiple Items Cases
In this final step of our Android Compose tutorial, we will introduce a bit more complex scenarios by implementing tests that leverage the utility functions we’ve developed so far. These tests are designed to handle multiple items in our todo app, simulating real-world use cases and ensuring our app functions correctly with multiple todo items.
Add three new test cases to MainActivityTest.kt
.
...
class MainActivityTest {
...
@Test
fun shouldDisplayAllAddedTodoItems() {
val todoText1 = "Walk the dog on the moon"
val todoText2 = "Teach the cat quantum physics"
val todoText3 = "Convince a kangaroo to do yoga"
ActivityScenario.launch(MainActivity::class.java).use {
addTodoItem(todoText1)
addTodoItem(todoText2)
addTodoItem(todoText3)
composeTestRule.onNodeWithText(todoText1).assertIsDisplayed()
composeTestRule.onNodeWithText(todoText2).assertIsDisplayed()
composeTestRule.onNodeWithText(todoText3).assertIsDisplayed()
}
}
@Test
fun shouldDisplayRemainingTodoItemsAfterOneIsDeleted() {
val todoText1 = "Walk the dog on the moon"
val todoText2 = "Teach the cat quantum physics"
val todoText3 = "Convince a kangaroo to do yoga"
val todoItem2Position = 1
ActivityScenario.launch(MainActivity::class.java).use {
addTodoItem(todoText1)
addTodoItem(todoText2)
addTodoItem(todoText3)
deleteTodoItem(todoItem2Position)
composeTestRule.onNodeWithText(todoText1).assertIsDisplayed()
composeTestRule.onNodeWithText(todoText3).assertIsDisplayed()
composeTestRule.onNodeWithText(todoText2).assertIsNotDisplayed()
}
}
@Test
fun shouldToggleStateOfSpecificTodoItem() {
val todoText1 = "Walk the dog on the moon"
val todoText2 = "Teach the cat quantum physics"
val todoText3 = "Convince a kangaroo to do yoga"
val todoItem3Position = 2
ActivityScenario.launch(MainActivity::class.java).use {
addTodoItem(todoText1)
addTodoItem(todoText2)
addTodoItem(todoText3)
toggleTodoItemState(todoItem3Position)
composeTestRule
.onAllNodesWithTag(TODO_ITEM_UI_CONTAINER_TEST_TAG)[0]
.assertContentDescriptionEquals(TODO_ITEM_UI_CONTAINER_UNCHECKED_CONTENT_DESCRIPTION)
composeTestRule
.onAllNodesWithTag(TODO_ITEM_UI_CONTAINER_TEST_TAG)[1]
.assertContentDescriptionEquals(TODO_ITEM_UI_CONTAINER_UNCHECKED_CONTENT_DESCRIPTION)
composeTestRule
.onAllNodesWithTag(TODO_ITEM_UI_CONTAINER_TEST_TAG)[2]
.assertContentDescriptionEquals(TODO_ITEM_UI_CONTAINER_CHECKED_CONTENT_DESCRIPTION)
}
}
...
shouldDisplayAllAddedTodoItems()
This test case ensures the app's capability to display multiple todo items simultaneously. By adding several distinct items and verifying their visibility in the UI, this test confirms that the app can handle multiple entries effectively.
shouldDisplayRemainingTodoItemsAfterOneIsDeleted()
Validates the app's delete functionality, particularly focusing on the visibility and integrity of remaining items post-deletion. By deleting one item from a list of added todos and checking that the rest are still displayed, this test ensures that the app's deletion process is precise and does not inadvertently affect other items.
shouldToggleStateOfSpecificTodoItem()
Checks the app's ability to change the state of individual todo items within a list. This test involves toggling the completion state of one item and confirming that this action only affects the targeted item, ensuring the app accurately handles state changes in a multi-item context.
We’ve crossed the finish line, yet there’s a twist.
Each of the six test cases we’ve meticulously crafted can be executed individually to confirm their successful passage.
Click the dual-play icon adjacent to the class definition to initiate all tests simultaneously, and behold what happens.
If many tests fail, it’s a common challenge in Android development. The trouble starts when we use the actual Room database during our MainActivity.kt
setup for testing. Since one test can leave data behind in the database, it can interfere with subsequent tests, such as when a test adds text1
and another test checks for its absence but finds it due to the previous test. This overlap makes it difficult to trust our test outcomes, particularly when running all tests together—a crucial step for automated testing processes or setups like Continuous Integration and Continuous Delivery (CI/CD).
To solve this issue, one approach could be to clear the Room database after each test or use different todo item names for all the test cases. But there are better solutions than these, as they continue the bad practice of setting up the database inside the MainActivity
's onCreate()
method. A better solution is to use dependency injection with a DI framework that allows for a cleaner integration of Room DB.
Don’t worry, we’ll cover this topic in another tutorial. For now, we have developed six solid UI tests that prove our Todo App works well. Our upcoming tutorial will use Dagger Hilt for dependency injection to enhance our app’s architecture. By doing so, we aim to fortify the structure of our application, ensuring it is as robust and dependable as the tests we conduct to verify its functionality.
Wrapping Up
That concludes our tutorial! We sincerely thank you for participating. We’re confident that you now have a clearer understanding of how to design real-case scenario UI tests.
🔗 Access the Full Code: Interested in diving deeper? The complete code is available to explore or use as inspiration for your projects. Access it here. This repository is a comprehensive reference for all the concepts we’ve discussed.
👍 Feedback and Support: Was this tutorial beneficial for you? Your feedback is vital to us and drives our commitment to sharing knowledge. Don’t hesitate to comment, applaud, or follow us for more insightful content.
Thank you for embarking on this learning journey with us. We wish you happy coding and eagerly await our next tutorial together!
Explore Further
Hungry for more learning? Please take a deep dive into our Android Compose Tutorials Library. This collection spans various UI design strategies using Jetpack Compose, offering detailed and carefully designed guides. They are tailored to enhance your technical abilities and spark your creativity in Android development!
Deuk Services: Your Gateway to Leading Android Innovation
Are you looking to boost your business with top-tier Android solutions?Partner with Deuk services and take your projects to unparalleled heights.