In the world of mobile development, it is common to use background tasks to improve applications usability, as well as their 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 API access operations, file reading or writing, or any other non-immediate execution task in the background.

This way, we can keep the Android main thread (UI Thread) free to execute tasks that are not directly related to the UI, using separate threads instead.

Now, how do background tasks affect when doing UI testing on an app?

Let’s start with an example

Some time ago, I was developing a very simple app. It consisted of 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. If you are curious to see the project in detail, you can find it in our GitLab.

Below are three screenshots of this simple app, which show the behavior of the search engine:

When we enter a text string, a ProgressBar appears, which remains visible for the time it takes to retrieve and process the data from PunkAPI. Finally, once ready, the data appears in a list.

This data recovery and processing task is carried out in the background and, as a result, during its execution the UI is not blocked and remains available to the users in case they decide to modify their search or perform any other action on the UI.

Okay, I know that the functionality of the app is very simple and I’m sure you want to go right now to see how we can do a UI test on this screen.

Let’s go with the test!

First of all, in order not to extend the post too much, I will omit some parts of the code mainly related to configuration, which you can consult in the code repository if you consider it necessary.

We start our test by configuring a mock server for our app, which we need to replicate the necessary behavior of the real server so that the search in our test is carried out successfully.

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 our mock server to return two test results, as long as we search for beers whose name contains the letter “F”.

We already have our test server configured, although we have not done any search yet, so the first assertion we make in our test is that the number of elements 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 define and invoke the 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 we have entered the text, the search task should be executed, connecting to the server to obtain the data. We are going to check that the number of elements in the RecyclerView is equal to 2, which corresponds to the number of beers that the search should 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"
                )
            )
    }

   . . .
}

It may seem that the test is ready, but if you try to run it, you will realize that it fails. The reason is that we are not considering that a task is being carried out in the background in our search.

Without taking into account the asynchrony produced when creating a background task to retrieve beers from the API, we are checking immediately after writing the search string that our RecyclerView contains the expected number of results, not giving time to the background task to be carried out.

On the other hand, we are not checking the appearance of the ProgressBar, a functionally necessary element to indicate to the user that a data load is being carried out and whose appearance and disappearance should be considered in our UI test.

Taking into account that the ProgressBar appears while the background task is running and that it disappears once it finishes, 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"
                )
            )
    }

   . . .
}

We already know what we need. Now, how can we implement it? Fortunately, Android offers a solution. Have you heard of ViewActions?

View Action to the rescue!

ViewActions allow us to execute interactions on views of our app. What we are going to do is develop a ViewAction that allows us to wait until the view goes to a state that we previously defined.

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 by constructor a variable of type Matcher<View>, which accepts Matchers and ViewMatchers, which we will use 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.

GetConstraints method allows us to apply restrictions on the views which our ViewAction can interact with. In our case, we do not need to apply any restrictions, so we allow any type of view.

On the other hand, perform method contains the specific action to be executed on the view. This method receives as parameters an UiController, which allows us to execute 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 expect.

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 a maximum period of time for the view to reach the state we expect. As you can see in the following while loop, our WaitAction checks, through the Matcher that we sent by constructor, that the view has reached the expected state within the defined maximum time.

At the end of each loop iteration, we call loopMainThreadUntilIdle method, which postpones the execution of the rest of the code until the UI Thread is idle, that is, without any pending tasks to be executed. In this way we ensure 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, our WaitAction will terminate or, in case the maximum wait time has been consumed, an exception will be raised that will cause our test to fail.

And finally, there’s only one thing left: using our WaitAction in our 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 define two WaitActions, one that waits for the ProgressView to be visible and the other for it to stop being visible. At this point, our background task will be finished and we will already have the data in our RecyclerView.

To end…

In this post, I have shared with you how, by making use of a ViewAction, we can manage events on views of our application dependent on background tasks in our UI tests.

I would love to hear your experiences and opinions. I encourage you to share them in the comments section!