Tomáš Repčík - 6. 2. 2024

End-To-End Testing With Robot Pattern And Jetpack Compose

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.

What is the robot pattern?

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()
    }
}

Advantages of the Robot pattern:

Robot pattern template

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()))

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

    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))
            )
        )
    }

    @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.

Real example

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, 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:

Robot creation for the main screen

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:

Main screen tests

@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()
    }
}

Conclusion

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.

Subscribe for more
LinkedIn GitHub Medium Threads X Bluesky