Simplify Compose UI testing with Robot Test Architecture

Thaw Zin Toe
ProAndroidDev
Published in
5 min readMar 10, 2024

--

When people see UI tests, it is so expensive to compose UI testing because our design always changes frequently and is unstable.

But the best application needs to test UI testing and we need to check Happy Flow testing with QA in the Jira ticket when you finish your task. But The project was getting bigger and bigger, the harder it was to refactor to test with UI tests.

Today, we gonna talk about how can we refactor UI tests with Robot Pattern in the big project and how easy to deal with this.

What is a Robot Pattern?

Let’s talk about the Robot Pattern. Created by Jake Wharton and first seen in Kotlin Night, in May 2016, this approach is all about making UI testing clearer and more structured, but the cool thing is, it’s not just for Kotlin; it can be used in various programming languages. Think of it as drawing a line between the ‘what’ and the ‘how’ of your tests.

So, why is the Robot Pattern a big deal?

Well, it keeps your tests separate from the main actions controlling your app’s views.

In this setup, the tests are all about the ‘what’ needs to be done, while the robots handle the ‘how’ it’s done. And this pattern isn’t just for Android apps. It works wonders for iOS apps with XCUIT too!

Robot UI Test Architecture

Normal Test Without Robot Pattern

Before introducing the Robot pattern, tests might look repetitive, focusing on the direct use of ComposeTestRule for every UI interaction and assertion:

Let’s think about the Login UI test with behavior

Behavior

  1. setup composable LoginScreenContent
  2. Type something in the email input field
  3. Type something in the password input field
  4. click the SignIn button and navigate to Home
  5. Show text in HomeScreen
class LoginTest {

@get:Rule
val composeTestRule = createComposeRule()

@Test
fun testLoginBehavior_withValidLogin_navigateToHome() {
// Launch the app to login screen
composeTestRule.setContent {
LoginScreenContent(
screenUiState = screenUiState,
onClickSignIn = { _,_ -> }
)
}

// check email input field exists and type test@gmail.com
composeTestRule.onNode(hasText("Email")
.assertExists()
.performTextInput("test@gmail.com")

// check password input field exists and type T@st1234
composeTestRule.onNode(hasText("Password")
.assertExists()
.performTextInput("T@st1234")

// check isEnabled for sign button
// hasClickLabel is custom prebuilt semantic matcher
composeRule.onNode(hasClickLabel("Sign In"))
.assert(isEnabled())
.performClick()

// Check "Home" text display in Home screen
composeRule.onNode(hasText("Home Screen Content")
.assertIsDisplayed()
}
}

What is the difference between traditional vs robot pattern testing in this

  1. Test methods using the compose rule can quickly become repetitive and unreadable.

Each screen or user flow may require click events, text presence verification, list item checks, and so on.

2. By using the robot pattern, you create a ‘robot’ for each screen or user flow in your app. This ‘robot’ has methods that mimic user behavior.

You can then combine and chain them into various tests with different goals. The robot knows how to click your ‘Sign In’ button, and more.

Implementing the Robot Pattern above Compose UI end-to-end test

We first identify the key screens or user flows in our application to implement the Robot Pattern. For each screen, we create a corresponding robot class. For example, in our app, we might have an authentication feature LoginRobot, SignUpRobot, and ForgetPasswordRobot.

Each robot class receives the ComposeTestRule as a constructor parameter. This ensures it has the necessary context to interact with the UI. The methods within these classes use the ComposeTestRule to perform actions, such as clicking buttons, and assertions, such as checking for text visibility.

Below is a simplified version of what implementing the Robot Pattern might look like for our login feature UI test scenario:

class LoginRobot(private val composeRule: CustomTestRule){
var screenUiState = mutableStateOf<ScreenUiState>(ScreenUiState.Success)

fun setUpLoginScreen(): LoginRobot {
composeRule.setContent {
MaterialTheme {
Surface{
SignInScreenContent(
screenUiState = screenUiState.value,
onClickSignIn = { _,_ -> }
)
}
}
}
return this
}

fun setScreenUiState(uiState: ScreenUiState): LoginRobot {
screenUiState.value = uiState
return this
}

fun performEmailTextInput(text: String): LoginRobot {
composeRule.onNode(hasText("Email"))
.assertExists()
.performTextInput(text)
return this
}

fun performPasswordTextInput(text: String): LoginRobot {
composeRuleonNode(hasText("Password"))
.assertExists()
.performTextInput(text)
return this
}

fun waitForIdle(): LoginRobot {
composeRule.waitForIdle()
return this
}

// assertButtonIsEnabled is custom prebuilt semantic matcher
fun assertSignInButtonIsEnabled() = assertButtonIsEnabled("Sign in")

fun clickSignInButton(): LoginRobot {
clickTextButton("Sign in")
return this
}

fun assertTextDisplayed(text: String, ignoreCase: Boolean = false, substring: Boolean = false): LoginRobot {
composeRule.onNode(hasText(text, ignoreCase = ignoreCase, substring = substring))
.assertIsDisplayed()
}
}

This LoginRobot shows “how” can we do in our Login behavior.

class LoginScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()

private lateinit var loginRobot: LoginRobot

@Before
fun setUp() {
loginRobot = LoginRobot(composeTestRule)
}


@Test
fun testLoginBehavior_withValidLogin_navigateToHome() {
loginRobot
.setUpLoginScreen()
.performEmailTextInput("test@gmail.com")
.performPasswordTextInput("T@st1234")
.assertSignInButtonIsEnabled()
.clickSignInButton()
.assertTextDisplayed("Home Screen Content")
}
}

This LoginScreenTest shows “what” can we do in our Login behavior.

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)) {
HomeRobot(composeTestRule).checkIdling()
openSettingsScreenViaDrawer()
SettingsRobot(composeTestRule).checkScreenContentWithoutAdvancedTracking()
openFavoriteScreenViaDrawer()
FavoriteRobot(composeTestRule).checkFavoriteScreen()
openLogoutViaDrawer()
LogoutRobot(composeTestRule).logout()
}
}

Quick Tip: Keep the Production Code Clean

For quick development and initial test runs, using testTags can ensure that everything is properly tested. The testTags are:

  • Easy to implement
  • Quickly testable
  • Unambiguous

Despite these benefits, there are significant drawbacks:

Test tags can clutter your production code. Ideally, production code should be free of testing-related elements, or contain them to a minimal degree.

Other disadvantages include:

  • The need to keep test tags organized
  • The risk of being forgotten during code refactoring
  • Potential waste of time assessing test failures

That’s why you must not be used testTags in your application as much as possible.

Wrapping Up: Easy and Quality UI Testing

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, customize it as you want to suit your needs to make your life easier, and deliver the higher quality app to the store.

Thank you for joining me today. Keep testing, keep learning, and keep coding!

References

--

--