This is a translation of an article by leading Android and iOS developer Yahoo (Verizon Media) Brama Yeh. He announces the introduction of the PageObject pattern into his instrumented tests, which makes them more flexible and easily modifiable based on changes in the user interface. Moreover, according to Bram, thanks to the DSL in Kotlin, the PageObject pattern has become more meaningful and more readable in test cases.
Let’s start
Define a base Page class that has a fun <reified T: Page> on (): T function that instantiates a PageObject of type T:
open class Page {
companion object {
inline fun <reified T : Page> on(): T {
return Page().on()
}
}
inline fun <reified T : Page> on(): T {
val page = T::class.constructors.first().call()
page.verify()
return page
}
}
Then all rest objects should be extended from Page
class ItemPage : Page() {
fun withTitle(keyword: String): ItemPage {
Espresso.onView(withId(R.id.productitem_name))
.check(matches(ViewMatchers.withText(keyword)))
return this
After that, we can write our test case as follows:
@Test
fun testSearchById() {
Page.on<DiscoveryPage>()
.on<SearchBoxPage>()
.click()
.on<SearchViewPage>()
.searchKeyword("7882691")
.on<ItemPage>()
.withTitle("A1NJ5J02")
}
UI testing on Android
Android UI tests are usually performed on physical devices and emulators (we, for example, use Espresso) – and there are a lot of them in our project. In the past, we set up a lot of helper methods to implement UI tests. This made our test function concise but difficult to understand the behavior, navigation, and user interface processes we were testing.
And when an app’s UI is updated frequently, testing it becomes a maintenance nightmare. Helper methods are useful, but when the UI changes very frequently, it becomes very difficult to determine which of these methods correspond to the updated user interface, unless the UI tests fail or we are monitoring the code carefully.
PageObject Pattern
The general rule of thumb for PageObject is that it should allow the software client to do and see what the user sees. They should also provide a simple programming interface while hiding the implementation details of the screen.
Martin Fowler
Benefits of PageObject
Reducing the amount of duplicate code
Although helper methods also reduce code duplication, PageObject encapsulates and hides the details of the UI structure and widgets from test cases. Thus, we focus on the behavior of the test cases separately from the UI details and make them more readable.
Improving the convenience of maintaining test cases, especially for projects with frequent UI changes
With the PageObject pattern, we just need to customize one or more of the page objects when the user interface changes. In addition, we can easily find out which page objects should be modified as well. As a result, developers save a lot of time by not tracking down the test code and figuring out why this test case failed.
On the other hand, when developing a new dialog fragment or some complex UI component, we would also need to write an appropriate PageObject class that contains the appropriate default checks and the required mechanics. Any engineer can then quickly write new test cases by following the user interface workflow.
Improving the readability of test cases
I will explain our scenarios and details later, but for now I would like to share a real case.
@Test
fun testSearchById() {
Page.on<DiscoveryPage>()
.on<SearchBoxPage>()
.click()
.on<SearchViewPage>()
.searchKeyword("7882691")
.on<ItemPage>()
.withTitle("A1NJ5J02")
}
It is easy to see that this test goes through the Discovery snippet , clicks on the SearchBox , enters a keyword into the editable SearchView , and then displays the Item snippet with the specified title.
- PageObject can inherit from another PageObject
For example, many snippets contain a RecyclerView that supports common functionality but differs in validation and some special functionality. To implement a ScrollablePageObject that checks for recyclerview and common methods like click the nth item, another PageObject needs to extend the ScrollablePageObject and adapt (customize) them.
class ScrollablePage : Page() {
@IdRes
open val recyclerViewId: Int = R.id.recycler_view
fun clickItem(index: Int): Page {
Espresso.onView(withId(recyclerViewId))
.perform(RecyclerViewActions.scrollToPosition(index)
Espresso.onView(withId(recyclerViewId))
.perform(
RecyclerViewActions.actionOnHolderItem(
ItemMatcher(),
click())
.atPosition(index)
)
return this
}
}
class SearchResultPage: ScrollablePage() {
…
}
Another special case worth sharing is that we have many different types of fragments that contain different UI components, but we will implement them in the same XML layout. This is suitable for implementations of different page objects that inherit from the same base object.
Moreover, it will bring the readability, because you see .on<NormalItemPage>()
, and .on<LimitedTimeSaleItemPage>()
in test cases and will not be confused.
Prerequisites
A common implementation is that each method of the page object determines what the next will be and returns it. However, this causes some problems:
- Different navigation through the same operation
Typically, the same operation will cause the application to navigate to different fragments. For example, SearchViewPage.searchKeyword({id})
it goes to a product fragment, but .searchKeyword({brand name})
must go to a brand fragment.
One solution is to separate into different methods, for example searchById(id: String): ItemPage
and searchByBrand(brand: String): BrandPage
, but the essence of both is implemented identically:
Espresso.onView(allOf(withId(R.id.search_input), isDisplayed()))
.perform(clearText())
.perform(replaceText(keyword))
.perform(pressImeActionButton())
This code is duplicated, so we combine the final page object with our actual case, which looks like this:
@Test
fun testSearchById() {
Page.on<DiscoveryPage>()
.on<SearchBoxPage>()
.click()
.on<SearchViewPage>()
.searchKeyword("7882691")
.on<ItemPage>()
.withTitle("A1NJ5J02")
}
@Test
fun testSearchByBrand() {
Page.on<DiscoveryPage>()
.on<SearchBoxPage>()
.click()
.on<SearchViewPage>()
.searchKeyword("timberland")
.on<BrandPage>()
.withTitle("Timberland"
- Navigation “Back” from different entry points ( * entry points – the address in RAM from which the program starts, in other words: the address at which the first command of the program is stored)
Some snippets will be created from different entry points – for example, a product page from a search snippet or a brand page by clicking on a product from a list. And sometimes the same fragments must be returned on a different stack after different behavior results. As mentioned above, we won’t let you back()
react in any way:
@Test
fun testItemDetail() {
Page.on<ItemPage>()
.clickDetail()
.on<WebPage>()
.withTitle("The Product Details")
.back()
.on<ItemPage>()
}
@Test
fun testBrandDetail() {
Page.on<BrandPage>()
.clickDetail()
.on<WebPage>()
.withTitle("The Brand Details")
.back()
.on<BrandPage>()
}
- Step into child component without any action
PageObject will not only create page objects for each fragment, but also for fragment elements and dialog boxes. The page object does not have to display the entire page, because in the following example, SearchBoxPage represents a child UI component inside a DiscoveryPage , which represents a discovery fragment .
@Test
fun testSearchById() {
Page.on<DiscoveryPage>()
.on<SearchBoxPage>()
.click()
.on<SearchViewPage>()
.searchKeyword("7882691")
.on<ItemPage>()
.withTitle("A1NJ5J02")
}
Architecture design
We define a base class Page
from which all other objects on the page inherit. The base class has a secondary function fun <reified T : Page> on(): T
that returns a PageObject instance by type T
, so we can concatenatePage.on<{PageObject}>()
at any time and determine the current page object, relying entirely on test operations, regardless of the method it executes.
This idea came from Vivian Liu’s talk, Design Patterns in XCUITest . Thanks to Vivian for sharing it at iPlayground 2018 in Taiwan.
Page.on()
gets the generic T
and returns the actual instance T
. We could make each page object a singleton and find a matching one, but that would modifyPage.on()
every time a new PageObject is created , this is poorly supported, so we use T :: class.constructors.first().call()
to get the generics constructors and get the first, usually nonparametric, constructor to instantiate T
.
open class Page {
companion object {
inline fun <reified T : Page> on(): T {
return Page().on()
}
}
inline fun <reified T : Page> on(): T {
val page = T::class.constructors.first().call()
page.verify()
return page
}
open fun verify(): Page {
// Each subpage should have its default assurances here
return this
}
fun back(): Page {
Espresso.pressBack()
return this
}
}
Reified
in Kotlin is useful to make the test case more meaningful. Otherwise we would have to write it by creating page objects. It will not be wrong, but it will have no connection between actions.
// with reified
Page.on<DiscoveryPage>()
.on<SearchBoxPage>().click()
.on<SearchViewPage>().searchKeyword("7882691")
// without reified
DiscoveryPage()
SearchBoxPage().click()
SearchViewPage().searchKeyword("7882691")
Page
also implements a function fun back(): Page
that returns the base Page class, because we don’t need to back()
respond to a specific page object. This solution allows us to easily specify which page object is returned to us after the back action.
And don’t forget that other objects on the page must inherit Page and configure verify () to perform the default checks.
class ItemPage : Page() {
override fun verify(): Page {
Espresso.onView(withId(R.id.productitem_name))
.check(matches(withEffectiveVisibility(VISIBLE)))
return this
}
fun withTitle(title: String): ItemPage {
Espresso.onView(withId(R.id.productitem_name))
.check(matches(ViewMatchers.withText(keyword)))
return this
}
}
class SearchViewOage : Page() {
override fun verify(): SearchView {
Espresso.onView(withId(R.id.search_input))
.check(matches(withEffectiveVisibility(VISIBLE)))
return this
}
fun searchKeyWord(keyword: String): Page {
Espresso.onView(allOf(
withId(R.id.search_input),
isDisplayed()
))
.perform(clearText())
.perform(replaceText(keyword))
.perform(pressImeActionButton())
return this
}
}