PageObject Pattern via Kotlin into Android UI tests
To make our Instrument test more flexible and easily modified by our UI changes, we adopt PageObject in the Instrument tests. Furthermore, with Kotlin’s DSL, the PageObject pattern became pithier and more readable in these test cases.
TL;DR
Define a basic Page
class which has the function fun <reified T : Page> on(): T
that generates PageObject instance by 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 those all other page objects inherit from Page
.
class ItemPage : Page() {
fun withTitle(keyword: String): ItemPage {
Espresso.onView(withId(R.id.productitem_name))
.check(matches(ViewMatchers.withText(keyword)))
return this
}
}
Thereupon we can write our test case as
@Test
fun testSearchById() {
Page.on<DiscoveryPage>()
.on<SearchBoxPage>()
.click()
.on<SearchViewPage>()
.searchKeyword("7882691")
.on<ItemPage>()
.withTitle("A1NJ5J02")
}
Android UI Test
Android UI unit tests run on physical devices and emulators, we use Espresso, and there are many UI tests in our project. Formerly, we customize lots of utils methods to implement UI tests. That made our test function brief but challenging to understand the behaviors, navigation processes, and this UI we tested. When the app’s UI revamps frequently, UI tests become nightmarish to maintain.
These utils methods are helpful. But when UI changes very frequently, it isn’t easy to find which utils methods are corresponding to revamped UI, unless UI tests failed or we trace code cautiously.
PageObject Pattern
The basic rule of thumb for a page object is that it should allow a software client to do anything and see anything that a human can. It should also provide an interface that’s easy to program to and hides the underlying widgetry in the window. — Martin Fowler
I can’t explain the concept of PageObject better than Martin even though he described this pattern at the development of Web. I strongly recommend reading his article here.
Advantages of PageObject
- Reduce the amount of duplicated codes.
Even though utils methods also reduce the duplicated codes, PageObject encapsulates and hides the details of the UI structures and widgets from these test cases. Thus we focus on the behaviors of test cases away from UI details and make test cases more readable.
- Enhance the test cases’ maintainability, especially for projects with frequent UI changes.
With PageObject, we only need to adjust one or some page objects when UI has varied. And it’s easy to know which page objects should be modified together. Hence, engineers save much time without tracing test codes to find why that test case failed.
On the other hand, when developing a new fragment, dialog, or some complicated UI component, we would also write the corresponding PageObject class, which contains its default assurances and required mechanics. After that, others and I would quickly write new test cases by following the UI operating sequence flow.
- Improve the readability of test cases.
I will explain our scenarios and details later, and 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’s easy to understand this test case goes through Discovery fragment, and clicks the SearchBox view, enters a keyword in editable SearchView, and then lies in Item fragment with the designated title.
- PageObject can inherit from other PageObject
For example, many fragments contain a RecyclerView, supports standard features, but different assurance and some special functions. To implement a ScrollablePageObject, which verifies whether RecyclerView exists and has general methods such as “click the nth item,” then let other PageObject extend ScrollablePageObject and customize theirs.
class ScrollablePage : Page() {
@IdRes
open val recyclerViewId: Int = R.id.recycler_viewfun 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 particular case is good to share: we have many different types of item fragment, which have various UI components, but we implement them in the identical resource layout XML. It’s suitable to implement different page objects but inherit from the same base one.
Furthermore, it will bring readability, because you will see
.on<NormalItemPage>()
andon<LimitedTimeSaleItemPage>()
inside test cases and won’t confound.
Preconditions
A typical implementation is that each page objects’ method decides what’s next page object and returns it. However, this causes some problems,
- Different destinations via the same operation
Usually, the same operation will make the app navigate to different destinations. For example, SearchViewPage.searchKeyword({id})
navigates to item fragment but .searchKeyword({brand name})
should go to brand fragment.
One solution is to separate by different methods, e.g. searchById(id: String): ItemPage
and searchByBrand(brand: String): BrandPage
, however, the essences of both implement identically,
Espresso.onView(allOf(withId(R.id.search_input), isDisplayed()))
.perform(clearText())
.perform(replaceText(keyword))
.perform(pressImeActionButton())
It’s duplicated, so we want to concatenate the eventual page object by our factual case, it will look like that
@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")
}
- Back navigation due to different entry points
Some fragments will come from the various entry points, e.g., item page from searching on landing fragment or click the product list of the brand fragment. And sometime, the same fragments should back to different back stack after various behavior outcomes. As mentioned above, we won’t let back()
respond to some definite one.
@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 the child component without practical actions
PageObject would not only build page objects for each fragment, but rather for the significant elements on a fragment and dialog. A page object needn’t represent an entire page, as the following sample, SearchBoxPage represents a child UI component inside DiscoveryPage that represents discovery fragment.
@Test
fun testSearchById() {
Page.on<DiscoveryPage>()
.on<SearchBoxPage>()
.click()
.on<SearchViewPage>()
.searchKeyword("7882691")
.on<ItemPage>()
.withTitle("A1NJ5J02")
}
Architecture Design
We define a basic Page
class, which all other page objects inherit. It has a minor function fun <reified T : Page> on(): T
that generates PageObject instance by type T
, in this way, we can concatenate Page.on<{PageObject}>()
at any time and determine what current page object is, entirely based on the test operations, breakaway which method it executes.
This idea arises from a session, Design Patterns in XCUITest by Vivian Liu, thanks for her sharing at iPlaygroud 2018 at Taiwan.
Page.on() receives generics T and yields an actual instance of T. We could make every page object singleton, and find the corresponding one, but this will modify Page.on() at each time when new PageObject creates, it’s not maintainable, so we use T::class.constructors.first().call()
to get generic’s constructors and get the first one, generally non-parameter constructor, to create an instance of 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
}
}
Kotlin’s reified
is hugely useful to make test case pithier. Otherwise, we had written our test case by generating page objects; it’s not wrong but hasn’t connections 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 the fun back(): Page
which returns basic Page class, because we needn’t that back()
responds definite page object. It’s easy for us by this solution to indicate which page object after back.
And, don’t forget other page objects must inherit Page and customize verify() to do default verifications,
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
}
}