In mobile development, it is very common to use background tasks to improve applications usability, as well as performance and error handling.
According to the Android development guides, any task that takes more than a few milliseconds should be delegated to a background thread. It is common to execute in the background API accesses, file readings and writings, and any other non-immediate execution task.
In this way, we can keep the main Android thread (UI Thread) free to be able to execute tasks that are not directly related to the UI, using separate threads instead.
Now, how do background tasks affect UI testing in an app?
Let’s start with an example
Some time ago, I developed a very simple app. It was a beer search engine (Do you know PunkAPI?) in which, by entering a text string, a list of the beers that contained it in their names was displayed.
Below are three screenshots of the search engine behavior of this simple application:
When we enter text, a ProgressBar appears, which is visible during the time it takes to retrieve and process the data from PunkAPI. Finally, once ready, data appears in a list.
The data processing and retrieval task is performed in the background, so the UI does not block during its execution and remains available for users to interact with, for example to modify their search or perform any other action.
Okay, I know this example is very simple and I’m sure you want to go right now to see how we can UI test this screen.
Let’s go with the test!
First of all, in order not to extend the post too much, I will skip parts of the code mainly related to configuration.
We start our test by setting up a mock server, which we need to replicate the behavior of the real server for the search in our test:
class BeerListFunctionalTest {
companion object {
private const val TEST_BEER_SEARCH_TEXT = "F"
private const val FAKE_RESPONSE_BODY = "" +
"[" +
" {" +
" \"id\": 1," +
" \"name\": \"Fake Beer 1\"," +
" \"tagline\": \"Fake tagline 1\"," +
" \"description\": \"Fake description 1\"," +
" \"image_url\": null," +
" \"abv\": 4.7" +
" }," +
" {" +
" \"id\": 2," +
" \"name\": \"Fake Beer 2\"," +
" \"tagline\": \"Fake tagline 2\"," +
" \"description\": \"Fake description 1\"," +
" \"image_url\": null," +
" \"abv\": 4.8" +
" }" +
"]"
}
. . .
@Test
fun beerSearchSucceeds() {
setupMockServer(200)
// TODO
}
. . .
private fun setupMockServer(httpCodeToReturn: Int) {
getMockWebServer().setDispatcher(object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
return when (request.path) {
buildFullApiPathForEndpoint() -> {
MockResponse().setResponseCode(httpCodeToReturn)
.setBody(FAKE_RESPONSE_BODY)
}
else -> {
MockResponse().setResponseCode(404)
}
}
}
})
}
private fun buildFullApiPathForEndpoint(): String =
"/${ApiConstants.API_BASE_PATH}/${ApiConstants.API_BEERS_PATH}" +
"?${ApiConstants.API_BEER_NAME_QUERY_PARAM}=$TEST_BEER_SEARCH_TEXT"
. . .
}
Through the setupMockServer method, we configure the mock server to return two test results, whenever beers whose name contains the letter “F” are searched for.
Next, since we haven’t done any searches yet, the first check we make in our test is that the number of items in the RecyclerView is 0:
class BeerListFunctionalTest {
. . .
@Test
fun beerSearchSucceeds() {
setupMockServer(200)
onView(withId(R.id.beerListRecyclerView))
.check(
RecyclerViewItemCountAssertion(
0,
"Guard: RecyclerView item count before filling search EditText should be 0"
)
)
// TODO
}
. . .
}
Now we are going to do a search, for which we invoke fillBeerSearchEditText method, which introduces the text string “F” in our EditText:
class BeerListFunctionalTest {
. . .
@Test
fun beerSearchSucceeds() {
setupMockServer(200)
onView(withId(R.id.beerListRecyclerView))
.check(
RecyclerViewItemCountAssertion(
0,
"Guard: RecyclerView item count before filling search EditText should be 0"
)
)
fillBeerSearchEditText()
// TODO
}
private fun fillBeerSearchEditText() {
onView(withId(R.id.beerSearchEditText)).perform(
typeText(TEST_BEER_SEARCH_TEXT)
)
}
. . .
}
Once the text has been entered, the search task is executed, connecting to the server to obtain the data, so we are going to verify that the number of elements in the RecyclerView is equal to 2, which corresponds to the number of beers that the search must return:
class BeerListFunctionalTest {
. . .
@Test
fun beerSearchSucceeds() {
setupMockServer(200)
onView(withId(R.id.beerListRecyclerView))
.check(
RecyclerViewItemCountAssertion(
0,
"Guard: RecyclerView item count before filling search EditText should be 0"
)
)
fillBeerSearchEditText()
onView(withId(R.id.beerListRecyclerView))
.check(
RecyclerViewItemCountAssertion(
2,
"RecyclerView item count after filling search EditText should be 2"
)
)
}
. . .
}
The test may appear to be ready, but if we run it, it will fail. The reason is that we are not considering the background task in our search.
Regardless of asynchrony, we are checking immediately after typing the search string that our RecyclerView contains the expected number of results, without giving the background task time to complete.
On the other hand, we are not checking the appearance of the ProgressBar, a necessary element to indicate to the user that a data load is being carried out and whose appearance and disappearance must be considered in our test.
Since the ProgressBar appears while the task is running in the background and disappears once it is finished, we are going to write at a high level what we need in our test to make it work:
class BeerListFunctionalTest {
. . .
@Test
fun beerSearchSucceeds() {
setupMockServer(200)
onView(withId(R.id.beerListRecyclerView))
.check(
RecyclerViewItemCountAssertion(
0,
"Guard: RecyclerView item count before filling search EditText should be 0"
)
)
fillBeerSearchEditText()
/* TODO:
1) Wait until ProgressBar is displayed
2) Wait until ProgressBar is not displayed
*/
onView(withId(R.id.beerListRecyclerView))
.check(
RecyclerViewItemCountAssertion(
2,
"RecyclerView item count after filling search EditText should be 2"
)
)
}
. . .
}
Okay, we already know what we need. Now, how can we implement it? Fortunately, Android offers a solution. Have you heard of ViewActions?
ViewAction to the rescue!
ViewActions allows us to execute interactions on views of our application. We are going to develop a ViewAction that allows us to wait until the view goes to the state we want.
class WaitAction(
private val matcher: Matcher<View>
) : ViewAction {
override fun getDescription(): String {
return "Waiting for specific action to occur"
}
override fun getConstraints(): Matcher<View> {
return any(View::class.java)
}
override fun perform(uiController: UiController, view: View?) {
// TODO
}
}
Our WaitAction receives a variable of type Matcher<View> by constructor, which accepts Matchers and ViewMatchers, which act as verifiers that a certain condition on the state of a view is met. In the case of our ProgressBar, whether it is visible or not.
The GetConstraints method allows to apply restrictions on the views with which our ViewAction interacts. In this case, we do not need to apply any restrictions, so we allow any type of view.
The perform method contains the action to be executed on the view. This method receives as parameters an UiController, which allows executing events on the Android UI, and the view on which the action is going to be executed. Within this method we are going to develop the action of waiting until the Matcher verifies that the view reaches the state we want.
Let’s see how it looks!
class WaitAction(
private val matcher: Matcher<View>
) : ViewAction {
companion object {
private const val TIME_OUT_MS = 1000
}
override fun getDescription(): String {
return "Waiting for specific action to occur"
}
override fun getConstraints(): Matcher<View> {
return any(View::class.java)
}
override fun perform(uiController: UiController, view: View?) {
val startTime = System.currentTimeMillis()
val endTime = startTime + TIME_OUT_MS
while (System.currentTimeMillis() < endTime) {
if (matcher.matches(view)) {
return
}
uiController.loopMainThreadUntilIdle()
}
throw PerformException.Builder()
.withActionDescription(this.description)
.withViewDescription(HumanReadables.describe(view))
.withCause(TimeoutException())
.build()
}
}
The first thing we do is define the maximum period of time that we will wait for the view to reach the state we want. Within the while loop, our WaitAction verifies, through the Matcher sent by the constructor, that the view has reached the expected state within the defined maximum time.
At the end of each iteration of the loop, the UiController method loopMainThreadUntilIdle is called, which postpones the execution of the rest of the code until the UI thread is idle, that is, without any pending tasks to run. In this way we make sure that any necessary action on the UI pending to be executed is done before verifying that our condition is met.
If the view reaches the expected state, the WaitAction will terminate or, in case the maximum wait time has been consumed, an exception will be thrown that will cause the test to fail.
Finally, there’s only one thing left: use the WaitAction in the test:
class BeerListFunctionalTest {
. . .
@Test
fun beerSearchSucceeds() {
setupMockServer(200)
onView(withId(R.id.beerListRecyclerView))
.check(
RecyclerViewItemCountAssertion(
0,
"Guard: RecyclerView item count before filling search EditText should be 0"
)
)
fillBeerSearchEditText()
onView(withId(R.id.progressBar)).perform(WaitAction(isDisplayed()))
onView(withId(R.id.progressBar)).perform(WaitAction(not(isDisplayed())))
onView(withId(R.id.beerListRecyclerView))
.check(
RecyclerViewItemCountAssertion(
2,
"RecyclerView item count after filling search EditText should be 2"
)
)
}
. . .
}
We have defined two WaitActions, one that waits for the ProgressView to be visible and the other for it to stop being visible, at which point our background task will have finished and we will have the data available in the RecyclerView.
To end…
In this post, I have shared with you how with ViewActions we can manage in UI tests events on views of our application dependent on background tasks.
I hope it helps you. If you have any questions or want to share an experience with me, do not hesitate to contact me!