Make your end-to-end tests more explicit and readable for everyone

In this article, I want to show you how to systematize the writing of the end-to-end tests with the help of a Robot pattern. Moreover, I will share with you a template to spare you some time and how to extend it with robots on top of it. In the end, I want to share some tips and learnings after implementing this pattern.

The end-to-end tests are an essential part of the testing strategy. They mimic a user’s behaviour and go through the whole app to see if it works correctly. Even, though the tests require high maintenance and time to execute, they can make you aware of unintentional breaking changes.

If you tried to tinker with Jetpack Compose testing, you are familiar with the ComposeTestRule and its methods to some degree. Here is a small code snippet, of how it looks:

class ComposeTest {

@get:Rule
val composeTestRule = createComposeRule()

@Test
fun testMyComposable() {
// start the app
launchApp<MainActivity>()
// assert main screen title
composeTestRule.onNode(hasText("Main screen")).assertIsDisplayed()
// find some image with subtext
composeTestRule.onNode(hasContentDescription("Main image").and(hasAnySibling(hasText("Subtitle of the page")))).assertIsDisplayed()
// go ahead to another screen
composeTestRule.onNode(hasTextExactly("Next").and(hasClickAction())).performClick()
// wait for another screen
composeTestRule.waitUntilExactlyOneExists(hasText("Second screen"))
// check another image, if it is visible
composeTestRule.onNode(hasContentDescription("Second image")).assertIsDisplayed()
}
}

The compose rule methods can get repetitive and unreadable quite quickly in a test. Every screen or user flow needs some click events, at every screen we want to verify some text presence, check the list item, check the crossed checkmark etc.

With a robot pattern, you create a ‘robot’ for every screen or user flow of your app, which possesses methods which imitate the user’s behaviour. Afterwards, you can mix and chain them into multiple tests with different testing goals. Robot knows how to click your ‘Log in’ button, check if the first ToDo list item is present etc.

Here is something, that we want to achieve:

class ComposeTest {

@get:Rule
val composeTestRule = createComposeRule()

@Test
fun testMyComposable() {
launchApp<MainActivity>()
// call robot to operate the screen
with(FirstScreenRobot(composeTestRule)) {
assertMainContent()
clickNext()
}
// call another robot to operate following screen
with(SecondScreenRobot(composeTestRule)) {
assertSecondScreen()
}
}
}

Meanwhile, the robots hide the logic we used for the screen.

class FirstScreenRobot(val composeTestRule: ComposeTestRule) { 

fun assertMainContent() {
composeTestRule.onNode(hasText("Main screen")).assertIsDisplayed()
composeTestRule.onNode(hasContentDescription("Main image").and(hasAnySibling(hasText("Subtitle of the page")))).assertIsDisplayed()
}

fun clickNext() = composeTestRule.onNode(hasTextExactly("Next").and(hasClickAction())).performClick()
}

class SecondScreenRobot(composeTestRule) {
fun assertSecondScreen() {
composeTestRule.waitUntilExactlyOneExists(hasText("Second screen"))
composeTestRule.onNode(hasContentDescription("Second image")).assertIsDisplayed()
}
}

  • reusability of the robots in other tests
  • increased readability of the test — it is more verbose
  • the modularity of each robot — specializes in solving one screen/flow
  • reduction in side effects, because changes in one robot influence only that one robot

Methods get repeated across the robots. To avoid it, we want to abstract as many methods as possible. So here is one generalized version inherited by other robots and used in one of my projects.

In my experience, the interaction with texts, images and buttons can be abstracted in calls similar to the template.

abstract class Robot(val composeRule: ComposeTestRule) {
// assertion of buttons and clicking them
fun clickTextButton(text: String) = composeRule.onNode(hasTextExactly(text)).performClick()

fun clickIconButton(description: String) = composeRule.onNode(
hasContentDescription(description).and(
hasClickAction()
)
).performClick()
fun goBack() = clickIconButton("Back button") // uses the same description in all app

fun assertIconButton(description: String) =
composeRule.onNode(hasContentDescription(description).and(hasClickAction())).assertExists()

fun assertTextButton(text: String) = composeRule.onNode(hasText(text).and(hasClickAction())).assertExists()

fun assertTextButtonWithIcon(text: String, description: String) = composeRule.onNode(
hasText(text).and(hasClickAction()).and(
hasAnySibling(hasClickAction().and(hasContentDescription(description)))
).assertExists()
)

fun assertImage(description: String) =
composeRule.onNode(hasContentDescription(description)).assertExists()

// text assertions
fun assertText(text: String, ignoreCase: Boolean = false, substring: Boolean = false) =
composeRule.onNode(hasText(text, ignoreCase = ignoreCase, substring = substring))
.assertExists()

fun assertDoesNotExistText(
text: String, ignoreCase: Boolean = false, substring: Boolean = false
) = composeRule.onNode(hasText(text, ignoreCase = ignoreCase, substring = substring))
.assertDoesNotExist()

fun assertTextBesideImage(text: String, description: String) {
composeRule.onNode(
hasText(text).and(
hasAnySibling(hasContentDescription(description))
)
).assertExists()
}

@OptIn(ExperimentalTestApi::class)
fun waitFor(matcher: SemanticsMatcher) = composeRule.waitUntilExactlyOneExists(matcher)
}

Of course, every app has different needs and feel free to customize the template to a needs of a project. Or just inspire yourself and create your own version.

Afterwards, the generalized robot can be inherited by any other robot and reuse its methods. For example:

class ExampleRobot(composeRule: ComposeTestRule): Robot(composeRule) {

fun checkScreen() {
waitFor(hasContentDescription("Example screen main icon").and(hasNoClickAction()))
assertImage("Example screen main icon")
assertText("This is example screen of tutorial.", substring = true)
assertTextButton("Next")
}

fun clickNext() = clickTextButton("Next")
}

ExampleRobot can be used in your UI tests as follows:

with(ExampleRobot(composeRule)) {
checkScreen()
clickNext()
}

Do not try to pack the template robot with every function, which you come across. You should actually keep screen specific methods in screen specific robots and nowhere else. The template should make your life easier with calls, which you do all the time in the app.

Recently, I was building myself an open-source Bluetooth app, which warns if my Bluetooth is on and there is no connected device. For example, I will use the main screen of the app. It just shows, if the background worker is turned on or off — it is just a glorified visualisation of one button.

The great thing about UI testing is, that you do not need to know how it works under the hood, because you are looking at the app from the user’s perspective.

The same goes for writing these tests because we want to observe the app and how it works and not to tinker with it.

For the creation of tests for the main screen, we need to know only that the animation on the image contains semantics stateDescription, which change following whether the service is running or not.

We want to create the following tests:

  • check if the job is off by default
  • try to turn on the job and check the change in UI
  • try to turn on and off the job
class MainRobot(composeRule: ComposeTestRule) : Robot(composeRule) {

fun checkIdling() {
checkAnimationOff()
assertText("Optimise Bluetooth usage")
assertTextButton("Turn on")
}

fun checkWorking() {
checkAnimationOn()
assertText("Checking Bluetooth perpetually")
assertTextButton("Turn off")
}

fun checkMainScreen() = composeRule.onNode(
hasStateDescription("Turned off").or(
hasStateDescription("Turned on")
).or(
hasStateDescription("Waiting to resolve issue")
)
).assertExists()

fun clickTurnOnButton() = clickTextButton("Turn on")

fun clickTurnOffButton() = clickTextButton("Turn off")

private fun checkAnimationOff() = composeRule.onNode(
hasStateDescription("Turned off")
).assertExists()

private fun checkAnimationOn() = composeRule.onNode(
hasStateDescription("Turned on")
).assertExists()
}

So the robot covers all the basic interactions with the main screen:

  • tapping on the button
  • checking the current state of the screen.
@LargeTest
@RunWith(AndroidJUnit4::class)
class MainScreenTest {
@get:Rule
val composeTestRule = createEmptyComposeRule()

@Test
fun checkIfJobIsOff() {
launchApp<MainActivity>()
MainRobot(composeTestRule).checkIdling()
}

@Test
fun checkIfJobIsOff_TurnItOn() {
launchApp<MainActivity>()
with(MainRobot(composeTestRule)) {
checkIdling()
clickTurnOnButton()
checkWorking()
}
}

@Test
fun checkIfJobIsOff_TurnItOnAndOff() {
launchApp<MainActivity>()
with(MainRobot(composeTestRule)) {
checkIdling()
clickTurnOnButton()
checkWorking()
clickTurnOffButton()
checkIdling()
}
}
}

As you can see, the final tests contain only a basic setup with the compose test rule and launch of the app itself. Afterwards, we give the control to the actual robot and it will drive the app by itself. The tests have become much more verbose and easy to understand even from a code perspective.

Moreover, this robot now can be combined with others in one test.

For example:

@Test
fun navigateThroughAppDrawerAllScreens() {
launchApp<MainActivity>()
with(NavigationRobot(composeTestRule)) {
MainRobot(composeTestRule).checkIdling()
openSettingsScreenViaDrawer()
MainSettingsRobot(composeTestRule).checkScreenContentWithoutAdvancedTracking()
openAboutScreenViaDrawer()
AboutRobot(composeTestRule).checkAboutScreen()
openMainScreenViaDrawer()
MainRobot(composeTestRule).checkMainScreen()
}
}

If you create such a robot for every screen, you will be able to create UI tests with ease and test pretty much anything. Take the template, customise it as you want to suit your needs to make your life easier and deliver higher quality app to the store.

The examples are coming from my hobbyist open-source project called BluModify and feel free to poke around and check the project on GitHub.

For more articles about Android:

Tomáš Repčík

Android development

Source link