Page Objects for E2E Android UI testing

Android end-to-end UI testing is not that difficult, but we have observed that the official API/tooling doesn’t scale much if you have a big app.

“Page Object” is a design pattern abstraction over a particular screen: it provides access to read the screen’s data and provides an API to interact with the screen. You may read the pattern description on Martin Fowler’s site.

To properly test UI on Android, the best option is to go with the Espresso testing framework. Espresso sees the “inner” of your app. This way you can more easily target the UI elements and use some powerful extensions, e.g. an idling resource that pauses test execution when the app is doing something.

Android studio is running a login UI test using our Page object infrastructure.

Overview

The common basic UI test may look like the following login example.

@Test
fun testLogin() {
    onView(withId(R.id.username_textview)).perform(replaceText("user"))
    onView(withId(R.id.password_textview)).perform(replaceText("123"))
    onView(withId(R.id.login_button)).perform(click())
}

The test is quite straightforward, yet it shows that view lookup may become quite repetitive action. In more complicated situations, the view lookup may also be the majority of the test’s lines of code and it may hide the actual tested scenario’s code.

Page Object design pattern splits the test code into two parts: a test takes care only about the testing scenario and it reuses Page objects encapsulating value retrieval and triggering user actions.

class LoginPage {
    fun fillUsername(value: String) { 
        onView(withId(R.id.username_textview)).perform(replaceText(value))
    }
    fun fillPassword(value: String) {
        onView(withId(R.id.username_textview)).perform(replaceText(value))
    }
    fun tapLogIn() {
        onView(withId(R.id.username_textview)).perform(click())
    }
}

@Test
fun testLogin() {
    with(LoginPage()) {
        fillUsername("user")
        fillPassword("123")
        tapLogIn()
    }
}

Page Chaining Infrastructure

Because every screen is represented by a Page object, we need to add an infrastructure to be able to work with multiple screens easily. Therefore, we created “runner” functions. The “navigation” Page object methods return a new Page object that is reused for another test block.

class LoginPage {
    // ...
    fun tapLogIn(): MainPage {
        onView(withId(R.id.username_textview)).performClick()
        return MainPage()
    }
}
class MainPage {
   fun tapSignOut() { /* ... */ }
}

@Test
fun testLogin() {
    on (LoginPage()) {
        fillUsername("user")
        fillPassword("123")
        tapLogIn()
    }.thenOn {
        tapSignOut()
    }
}

Those on() and thenOn() functions are quite similar to Kotlin’s with() and run() functions, yet we do additional stuff in them – more on this later. You could argue that it is not obvious what is the second Page object. Thankfully, Android Studio (IntelliJ platform) is showing type hints describing the “this” scope (take a look at the screenshot).

Page’s Safe Accessors

In Android, it is possible to use a single layout ID across multiple fragments/screens. To be safe when selecting a view, we automatically add a “page id” matcher. In other words, every Page requires defining its root layout id. This way, we scope every page’s access only to its children.

Also, let’s introduce other helpers for often-used actions.

abstract class Page(
    @IdRes
    private val rootLayoutId: Int,
) {
    protected fun onView(@IdRes id: Int): ViewInteraction {
        val rules = listOf(
            withId(id),
            isDescendantOfA(withId(rootLayoutId)),
        }
        return onView(allOf(rules))
    }
}

fun ViewInteraction.performClick(): ViewInteraction = perform(click())
fun ViewInteraction.replaceText(value: String): ViewInteraction = perform(replaceText(value))


class LoginPage : Page(R.id.login_root) {
    // ...
    fun fillPassword(value: String) {
        onView(R.id.username_textview).replaceText(value)
    }
}

Having a page’s root layout ID is a good thing — we may use it in our Page object runners. When switching a page, we always check if the new page’s root layout is present – in other words, we try to fail fast.

abstract class Page(
    @IdRes
    private val rootLayoutId: Int,
) {
    // ...
    fun checkLayout() {
        onView(withId(rootLayoutId)).check(isDisplayed())
    }
}

fun <T : Page, U> on(page: T, testBlock: T.() -> U): U {
    contract {
        callsInPlace(testBlock, InvocationKind.EXACTLY_ONCE)
    }
    page.checkLayout()
    val result = page.testBlock()
    check(result == null || result == Unit || result is Page) {
        "Test block lambda has to return Page object, null, or Unit."
    }
    return result
}

fun <T : Page, U> T.thenOn(testBlock: T.() -> U): U {
    contract {
        callsInPlace(testBlock, InvocationKind.EXACTLY_ONCE)
    }
    this.checkLayout()
    val result = this.testBlock()
    check(result == null || result == Unit || result is Page) {
        "Test block lambda has to return Page object, null, or Unit."
    }
    return result
}

Exposing Screen State

Espresso works the way, it provides functions that both read a value and assert it with an expected value. But using Page objects splits this paradigm and we need to read the value separately. The assertion is postponed to the actual test (phase). So let’s hack a bit by reading a view’s text value and then expose it through a page property.

fun ViewInteraction.getTextValue(): String {
    var result = ""
    perform(object : ViewAction {
        override fun getConstraints(): Matcher<View> {
            return ViewMatchers.isAssignableFrom(TextView::class.java)
        }
        override fun getDescription(): String {
            return "Read text of the view"
        }
        override fun perform(uiController: UiController?, view: View?) {
            val tv = view as TextView
            result = tv.text.toString()
        }
    })
    return result
}

fun ViewInteraction.isDisplayed(): Boolean {
    return try {
        check(isDisplayed())
        true
    } catch (_: Throwable) {
        false
    }
}

class MainPage : Page(R.id.main_root) {

    val isLoggedIn: Boolean
        get() = onView(R.id.account_email).isDisplayed()

    val accountEmail: String?
        get() = if (isLoggedIn) onView(R.id.account_email).getTextValue() else null

}

This way we hide implementation details and our testing code can work with a clean API. In the end, our testers doesn’t have to be bothered with all those implementation details and they can write only their testing scenario code.

Dynamic Forward Navigation

Those simple testing cases may get complicated when the forward navigation is “dynamic”. E.g. the final target depends on the current state of the page. There are two possibilities for how to model this:

  1. Model those actions as different methods returning a different Page object.
  2. Use generics and resolve the target page dynamically.

Both of those solutions have some pros and cons. Let’s see them and decide for yourself what suits you more.

class SignUpPage : Page(...) {
    fun tapSignUpExpectingSuccess(): MainPage {
        onView(R.id.signup_button).performClick()
        return MainPage()
    }

    fun tapLoginExpectingAccountAlreadyExists(): AccountExistsPage {
        onView(R.id.signup_button).performClick()
        return AccountExistsPage()
    }
}

// alternative approach

class SignUpPage : Page(...) {
    fun <T : Page> tapSignUp() : T {
        onView(R.id.signup_button).performClick()
        return createPage() as T
    }

    private fun createPage(): Page {
        // custom logic based on page state
        // if something else is returned than expected T, the cast will throw making the test fail
    }
}

Backward Navigation

Backward navigation is another kind of navigation. To naturally go back in a testing scenario, each Page has a goBack() method returning a “previous” Page object. To know the “previous” Page object, we pass it as a Page constructor’s argument.

abstract class Page<T : Page>(
    @IdRes val rootLayoutId: Int,
    val previousPage: T,
) {
    fun goBack(): T {
        return previousPage
    }
}

class AccountAlreadyExistsPage(
    previousPage: LoginPage
) : Page(R.id.account_exists_root, previousPage) {
}

class LoginPage : RootPage() {
    fun tapLoginExpectingAccountExists(): AccountAlreadyExistsPage {
        // ...
        return AccountAlreadyExistsPage(this)
    }
}

Sometimes, the page can be opened from multiple locations. To support those scenarios, we make the previousPage property generic (e.g. in AccountAlreadyExistsPage class). However, our Page objects get quite messy sometimes, as the variability usually propagates over multiple pages.

This is the most questionable attribute of our Page objects. I’d recommend introducing this rather for specific local use cases and lower the Page object’s complexity.

Idling Resources

Idling resources is a complex topic on its own, but basically they help us to pause test execution until a screen/app is idle. Idling resources may be both global and local. Therefore, we expose an optional list of the screen’s idling resources for every Page object. This list is utilized by our test runners to register them and also to unregister them when switching to another Page object.

abstract class Page(...) {
    open val idlingResources: List<IdlingResource>
         get() = emptyList()
}

fun <T : Page, U> on(page: T, testBlock: T.() -> U): U {
    // ...
    val registry = IdlingRegistry.getInstance()
    for (idlingResource in page.idlingResources) {
         registry.register(idlingResource)
    }

    page.checkLayout()
    val result = page.testBlock()

    for (idlingResource in page.idlingResources) {
         registry.unregister(idlingResource)
    }
    // ...
}

Compose Page Objects

Compose UI toolkit has a brand-new matcher/action API, yet the Espresso’s paradigm stays – you match a composable and directly call an assertion function.

When we started using Compose, we made the Page an interface and introduced another Compose Page implementation. For us, pages do not necessarily represent the “whole” screen, but they can model also some smaller part of it, e.g. a widget, that may be implemented in a different UI toolkit than the parent page. This way, we didn’t have to rethink our testing approach.

Compose use testTag string identifiers instead of ID integers. To share those test tags with the tests, we add a “Semantics” object for every Compose file and reuse those tags in the Compose Page object.

@Composable
fun LoginScreen() {
    Column(Modifier.testTag(LoginScreenSemantics.Tag)) { 
        // ...
        Button(Modifier.testTag(LoginScreenSemantics.LogInButton)) { /* ... */ }
    }
}

object LoginScreenSemantics {
    const val Tag = "login_screen"
    const val LogInButton = "login"
}

class LoginPage : ComposePage(LoginScreenSemantics.Tag) {
   fun tapLogIn() {
       onNode(LoginScreenSemantics.LogInButton).performClick()
   }
}

To match/look up a Compose node in the Page object, the ComposeTestRule instance is needed. We access the rule’s instance using static access.

Recording of UI test

Conclusion

UI testing is a difficult topic and using some advanced code structuring helps us to fight it. Page objects bring clarity, structure the testing code, and allow us to split responsibility among developers and testers.

What’s more, having Page objects covers implementation details and so we don’t have to touch actual testing code, e.g. when we rewrite our UI from View to Compose.