A couple of months ago, our team was tasked with overhauling one of our applications both visually and also by refactoring its codebase. As the application was fairly large with a dozen screens, we decided to gradually rewrite the app starting from the backend and business logic all the way to the views.
Due to the official introduction of Kotlin to Android development, we have naturally chosen it as the main language to write all of the new code in. This soon proved itself as a good choice because of the obvious advantages of Kotlin as a very concise and programmer-oriented language.
However, as the application has been in production for a couple of years, we had a quite large base of instrumentation tests already written to test all of its functions and screens. Unfortunately, there has been no real pattern backing those tests, so only a few of them were usable after the overhaul.
New Codebase & Robot Pattern
Facing this situation, we have started searching for a suitable approach to write the new test codebase to be very robust and easily maintainable in the future. This search has led us to the robot pattern, originally introduced by Jake Wharton in one of his lectures.
The main idea behind this pattern is to separate the concerns in UI tests (the what from the how), similarly as we do it in our main codebase by utilizing the MVVM architecture pattern. So the ultimate aim is to create stable, readable, and maintainable tests.
Jake Wharton presents the pattern as a very broad one, open to custom interpretations in any language with its main objective to separate the tests from the logic that controls the views.
Robots
This is achieved by the introduction of the so-called robots, that are purely responsible for controlling the individual screens or views, so the tests do not see the details of how the elements of the views are found and controlled, e.g. find an EditText and fill its value, or click a particular button. The robots represent the how and the tests represent the what. The best of all is that the tests are self-explanatory, they are easy to read and quite short. You can use one robot with multiple tests and if you change a view in the application, you do not have to change dozens of tests, just the particular robot that belongs to the changed view. A killer idea! And it gets even better when you use some advanced features of Kotlin.
Most of the implementations of the robot pattern get to a point where the tests are very terse with chained functions like in this example from Jake Wharton’s lecture:
payment {
amount(4200)
recipient("foo@bar.com")
} send() {
isSuccessful()
}
Which is a fantastic example, but when writing tests, we pair experienced programmers with juniors in our company, and some advanced concepts of Kotlin could be a bit difficult for them to understand. Moreover, we wanted to achieve a clear and uniform structure of the individual tests.
Our own implementation
This means that we had to come up with our own implementation that would preserve the principles of the pattern, would scope the operations to particular screens, and could be very effectively taught to people with minimal coding skills.
For that purpose, we divided the robots that interact with the screens to actors and inspectors, where the actors are responsible for the interaction with the specific elements and the inspectors are responsible for the verification of the displayed data. So each screen has 2 robots.
The actor is defined alongside the act() function in a file as:
interface Actor
abstract class ActorRobot : Actor {
protected val events: Events = Events()
}
fun <T : Actor> act(actor: T, func: T.() -> Unit) = with(actor, func)
The inspector is defined with the inspect() function as:
interface Inspector
abstract class InspectorRobot : Inspector {
protected val checkThat: Matchers = Matchers()
}
fun <T : Inspector> inspect(inspector: T, func: T.() -> Unit) = with(inspector, func)
The events and checkThat objects serve as collections of functions for interacting with the various elements of the GUI and as assertions, for instance for clicking on Buttons, editing EditTexts, or evaluating the elements of RecyclerViews.
The QA engineers then write the tests as sets of act() and inspect() functions defined by their scope (or robot), which is a particular actor or inspector as the function’s parameter. The interaction with the screens takes place by calling the functions of the scope. The programmers are limited in calling functions only within the defined scope, thus writing more readable and tidy tests by not mixing functions from different robots (screens). And the IDE is helping them with suggestions as well.
TVMZ
In order to demonstrate our approach, we have created a demo application. We call it TVMZ, because it communicates with the tvmaze.com API and serves as a simple search engine to search for and display the details of TV shows. The individual screens of the app are in the pictures below.
The screens of the TVMZ application
A standard Espresso test (without using the robot pattern) that checks the cast of the TV show Star Trek and inspects the screens all the way to the cast, would look like this:
@Test
fun showStarTrekDetailNoPattern() {
// Fill the search bar and click the submit button.
onView(ViewMatchers.withId(R.id.editTextSearchTitle))
.perform(ViewActions.clearText(), ViewActions.typeText("Star Trek"))
onView(ViewMatchers.withId(R.id.buttonSearch))
.perform(ViewActions.click())
// Check the fragment title and the expected item count.
onView(AllOf.allOf(IsInstanceOf.instanceOf(TextView::class.java), ViewMatchers.withParent(ViewMatchers.withId(R.id.toolbar)))).check(ViewAssertions.matches(ViewMatchers.withText("Found shows")))
onView(ViewMatchers.withId(R.id.recyclerView)).perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(9))
onView(ViewMatchers.withId(R.id.recyclerView)).check(RecyclerViewItemCountAssertion.withItemCount(9))
// Click on the first item
onView(ViewMatchers.withId(R.id.recyclerView)).perform(RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(0, ViewActions.click()))
// Evaluate the data on the detail screen.
onView(AllOf.allOf(IsInstanceOf.instanceOf(TextView::class.java), ViewMatchers.withParent(ViewMatchers.withId(R.id.toolbar))))
.check(ViewAssertions.matches(ViewMatchers.withText("Show detail")))
onView(ViewMatchers.withText("Star Trek")).check(ViewAssertions.matches(ViewMatchers.withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)))
An absolute nightmare to read. Not to mention the amount of work needed to rewrite the tests if some elements on the screens change. However, we have our simple little framework to help clean up the mess.
We have written 2 robots (an actor and an inspector) for each of the 4 screens. Those are solely responsible for the interaction with the UI elements and their inspection. An example of the robots for the search screen is:
class SearchScreen : ActorRobot() {
fun fillShowName(showName: String) {
events.typeText(R.id.editTextSearchTitle, showName)
}
fun submit() {
events.clickOnView(R.id.buttonSearch)
}
}
class SearchCheck : InspectorRobot() {
fun isScreenVisible() {
checkThat.viewIsVisible(R.id.editTextSearchTitle)
checkThat.viewIsVisible(R.id.buttonSearch)
}
}
And then the original test can be rewritten to this form:
@Test
fun showStarTrekDetail() {
act(SearchScreen()) {
fillShowName(showName)
submit()
}
inspect(ShowsListCheck()) {
isScreenVisible()
isExpectedItemCount(9)
}
act(ShowsListScreen()) {
clickOnItemAtPosition(0)
}
inspect(ShowDetailCheck()) {
isScreenVisible()
isTitleVisible(showName)
}
}
You can see that it’s much cleaner and more readable. Even though the test is longer than the example shown in the lecture, it follows a deterministic pattern. If the elements of the screen change, changing the tests won’t be that much work.
And because there is a clear pattern in these tests, anyone can learn to write them very easily. New members of the team can be trained in a very short time, so the programmers can be allocated more efficiently. We have proven that by rewriting the tests in our production app and by adding new functionality since then. The new functionality demanded only very minor changes to the test suite compared to the previous approach we have used.