This is the second of three articles about Jetpack Compose UI Testing series. As per the previous article, I’d like to begin with a quick introduction into some of the basic aspects, in order to understand the whole context of the topic.
I would like to start introducing and explaining the importance of the semantics API. As I already mentioned in my previous article:
Jetpack Compose uses semantics to give meaning to a piece of UI, apart from that, it’s primarily used for accessibility.
Compose and the View framework have fundamentally different architectures. Views are objects with a defined structure, state, and a clear hierarchy after creation; whereas composable functions do not. Once a composable has emitted its UI there is no possibility for tools to identify a particular component and interact with it. We need a way to describe our compose UI in a structured way in order for other tools to interact with it.
Semantics provide the required structured description of our UI. Every composable can define semantics. Therefore, Compose will generate a Semantics Tree alongside the UI.
With that being said, it is time to review the available tree node finders options in the testing field and analyse each of them from the Clean Code principles point of view.
Don’t mix Production Code and Test Code ⛔
Assuming that most of the developers have understood and applied the Clean Code principles perfectly described by Uncle Bob, there’s no doubt, when it comes to keeping the production code clear of test code, that it is not good practice to add test code when functions, classes, variables and so forth have been defined in production code.
In the official documentation, Testing your Compose Layout, it’s possible to find nodes using “hasTestTag”, which means the inclusion of test code in production code 🚩(RED FLAG).
composeTestRule.onNode(hasTestTag("Players")) | |
.onChildren() | |
.filter(hasClickAction()) | |
.assertCountEquals(4) | |
.onFirst() | |
.assert(hasText("John")) |
Example originally copied from the official Android documentation
Use Semantics and Finders 🏅
As mentioned before, Semantics give a meaning to a piece of UI and offer accessibility. Therefore, there is a better way to identify Buttons, Animations, Images, etc. through contentDescription.
@Composable | |
private fun FilterIcon(modifier: Modifier, onClick: () -> Unit) { | |
Box(modifier) { | |
IconButton( | |
onClick = onClick | |
) { | |
Image( | |
painter = painterResource(R.drawable.ic_filter), | |
contentDescription = "Filter Button" | |
) | |
} | |
} | |
} |
Production Code example
In the UI Test, it is as simple as:
private val dialogFilterButton by lazy { | |
composeTestRule.onNodeWithContentDescription("Filter Button") | |
} | |
@Test | |
fun elementsVisibilityAfterOpeningTheMainScreen() { | |
setMainContent() | |
dialogFilterButton.assertIsDisplayed() | |
} | |
Test Code example
Likewise, using Finders, it’s possible to define some text in an element by text. Then get the reference of that element in your test by using onNodeWithText. This is the most practical strategy, as well as most commonly used, to find elements in Compose which represent buttons.
Robot Pattern in Jetpack Compose 🤖
The Robot Pattern was designed by Jake Wharton at Square back in 2016. The power of this pattern is its ability to create an abstraction layer in order to interact with the UI in a declarative mode. Once created, it is then possible to perform multiple tests in order to verify our use cases without boilerplate code, as well as without maintenance problems related to a refactor.
Some of the big reasons are:
- Ease of understanding
This means we can quickly and easily read and understand what’s being tested without knowing exactly how the test works behind the scenes. - Reuse of code
By breaking down the tests into steps, each implementation step can be re-used as many times as needed. - Isolating implementation details
Whichever architecture your app uses, your aim is the single responsibility principle. Sticking to this allows you to switch out an object for a new one, with a new implementation, while still keeping the objects core functionality. This allows for code that is easier to maintain, test, and improve.
The Screen Robot
This class will allocate all of the screen elements and assertion functions:
private val launchesTabItem by lazy { | |
composeTestRule.onNodeWithText( | |
"Launches" | |
) | |
} | |
private val launchesListItems by lazy { | |
composeTestRule.onAllNodesWithContentDescription( | |
"Item", | |
substring = true, | |
useUnmergedTree = true | |
) | |
} | |
private val connectionErrorMessage by lazy { | |
composeTestRule.onNodeWithText("AN ERROR OCCURRED", useUnmergedTree = true) | |
} |
The screen elements get identified using semantics and/or node finders
Job Offers
Let’s look at the functions defined in this class:
fun clickOnLaunchesTab() = launchesTabItem.assertIsDisplayed().performClick() | |
fun initialElementsShowed() { | |
launchesTitle.assertIsDisplayed() | |
dialogFilterButton.assertIsDisplayed() | |
} | |
fun listItemsShowed(numItemsShowed: Int) = launchesListItems.assertCountEquals(numItemsShowed) | |
fun advanceTimeBy(timeToAdvance: Long) = composeTestRule.mainClock.advanceTimeBy(timeToAdvance) | |
fun errorElementsDisplayed() { | |
connectionErrorMessage.assertExists().assertIsDisplayed() | |
connectionErrorAnimation.assertExists().assertIsDisplayed() | |
} | |
fun noResultsElementsShowed() { | |
noResultsText.assertExists().assertIsDisplayed() | |
listItemsShowed(0) | |
} |
These functions will simplify the test case and will be reused in many cases
Comparison
Let’s compare the same UI Test using the Robot pattern and without too.
@Test | |
fun elementsVisibilityAfterOpeningTheScreen() { | |
launchesScreenRobot(composeTestRule) { | |
clickOnLaunchesTab() | |
initialElementsShowed() | |
} | |
} | |
@Test | |
fun elementsVisibilityAfterOpeningTheScreen() { | |
composeTestRule.apply { | |
onNodeWithText("Launches").performClick() | |
onNodeWithContentDescription( | |
"Launches Animation", | |
useUnmergedTree = true | |
).assertIsDisplayed() | |
onNodeWithContentDescription("Filter Button").assertIsDisplayed() | |
onNodeWithText("LAUNCHES").assertIsDisplayed() | |
} | |
} |
The first function implements Robot Pattern whereas the second one does not
In the previous example, there are not many elements involved; the assertions and interactions are very simple too. However, in a production environment we could potentially encounter a very complex structure which could be transformed into a concise and simplified Robot Pattern version.
DSL
One of the advantages of using the Robot Pattern in the UI Tests is that it’s possible to reference the Screen Robot class as a DSL, where it’s possible to pass the AndroidComposeTestRule as a parameter.
Let’s check how it should be defined:
internal fun launchesScreenRobot( | |
composeTestRule: AndroidComposeTestRule<ActivityScenarioRule<EntryPointActivity>, EntryPointActivity>, | |
func: LaunchesScreenRobot.() -> Unit | |
) = LaunchesScreenRobot(composeTestRule).also { func } | |
internal open class LaunchesScreenRobot constructor( | |
private val composeTestRule: AndroidComposeTestRule<ActivityScenarioRule<EntryPointActivity>, EntryPointActivity> | |
) { | |
// Robot definitions and functions | |
} |
The result will reflect that shown within the previous UI Test Example. All of the functions can be used inside the DSL, which removes the boiler plate and noise that usually has to be declared.
When NOT to apply Robot Pattern
The aim of this pattern is to abstract & reuse code, make the tests more concise and easy to read. As per other methodologies or alternative frameworks, we need to be pragmatic and ensure that we check whether the result is easier to read, or whether it overcomplicates the test’s definition task.
Full Example🧐
If you would like to check more details and see some of the tests examples I have defined with and without this pattern, make sure you check my Github example SpaceX prepare for Clean Architecture liftoff🚀