En el mundo del desarrollo mobile, es habitual recurrir a tareas en segundo plano para mejorar la usabilidad de las aplicaciones, así como el rendimiento y la gestión de errores de las mismas.
Según las guias de desarrollo Android, cualquier tarea que lleve más de unos pocos milisegundos debería delegarse a un subproceso en segundo plano. Es habitual ejecutar en segundo plano operaciones de acceso a API, lectura o escritura en ficheros, o cualquier otra tarea de ejecución no inmediata.
De esta manera podemos mantener el hilo principal de Android (UI Thread) libre de ejecutar tareas que no están directamente relacionadas con la UI, usando en su lugar hilos separados.
Ahora bien, ¿cómo afectan las tareas en segundo plano a la hora de plantear tests de UI sobre una app?
Partamos de un ejemplo
Hace un tiempo me encontraba desarrollando una app muy simple. Esta consistía en un buscador de cervezas (¿Conocéis PunkAPI?) en el que, introduciendo una cadena de texto, se mostraba un listado con las cervezas que la contenían en sus nombres. Si tenéis curiosidad por ver el proyecto en detalle, lo podéis encontrar en nuestro GitLab.
A continuación se muestran tres pantallazos de esta app tan simple, que muestran el comportamiento del buscador:

Cuando introducimos una cadena de texto aparece un ProgressBar, que se mantiene visible durante el tiempo que lleva recuperar y procesar los datos provenientes de PunkAPI. Finalmente, una vez listos, los datos aparecen en un listado.
Esta tarea de recuperación y procesamiento de datos se realiza en segundo plano y, gracias a ello, durante su ejecución la UI no se bloquea y sigue disponible para el usuario por si este decide modificar su búsqueda o realizar cualquier otra acción sobre la UI.
Vale, ya sé que el funcionamiento de la app es muy sencillo y estoy seguro de que queréis pasar ya mismo a ver cómo podemos hacer un test de UI sobre esta pantalla.
¡Vamos con el test!
Antes de nada, con el fin de no extenderme demasiado, omitiré algunas partes del código relacionadas principalmente con configuración, que podréis consultar en el repositorio de código si lo consideráis necesario.
Comenzamos nuestro test configurando un servidor de mock para nuestra app, que necesitamos para replicar el comportamiento necesario del servidor real para que la búsqueda en nuestro test se realice con éxito.
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"
. . .
}
A través del método setupMockServer, configuramos nuestro servidor de Mock para que nos devuelva dos resultados de prueba, siempre que busquemos por cervezas cuyo nombre contenga la letra “F”.
Ya tenemos configurado nuestro servidor de test, aunque aún no hemos realizado ninguna búsqueda, por lo que la primera aserción que hacemos en nuestro test es que el número de elementos en el RecyclerView sea 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
}
. . .
}
Ahora sí, vamos a realizar una búsqueda, para lo cual definimos e invocamos el método fillBeerSearchEditText, el cual introduce la cadena de texto “F” en nuestro 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)
)
}
. . .
}
Una vez hemos introducido el texto, la tarea de búsqueda debería ejecutarse, realizándose en ella la consulta al servidor para obtener los datos. Vamos a comprobar que el número de elementos en el RecyclerView sea igual a 2, que corresponde al número de cervezas que nos debería devolver la búsqueda.
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"
)
)
}
. . .
}
Puede parecer que el test está listo, pero si probáis a ejecutarlo os daréis cuenta de que falla. La razón es que no estamos considerando que en nuestra búsqueda se está llevando a cabo una tarea en segundo plano.
Sin tener en cuenta la asincronía producida al crear una tarea en segundo plano para consultar las cervezas a la API, estamos comprobando inmediatamente después de escribir la cadena de búsqueda que nuestro RecyclerView contenga el número de resultados esperado, no dando tiempo a la tarea en segundo plano a realizarse.
Por otro lado, no estamos comprobando la aparición del ProgressBar, un elemento funcionalmente necesario para indicar al usuario que se está llevando a cabo una carga de datos y cuya aparición y desaparición debería considerarse en nuestro test de UI.
Teniendo en cuenta que el ProgressBar aparece mientras la tarea en segundo plano está en ejecución y que desaparece una vez termina, vamos a plantear a alto nivel qué es lo que necesitamos en nuestro test para que funcione:
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"
)
)
}
. . .
}
Ya sabemos qué es lo que necesitamos, ahora bien, ¿Cómo podemos implementarlo? Afortunadamente, Android ofrece una solución ¿Habéis oído hablar de las ViewActions?.
¡View Action al rescate!
Las ViewActions nos permiten ejecutar interacciones sobre vistas de nuestra app. Lo que vamos a hacer es desarrollar una ViewAction que nos permita esperar hasta que la vista pase a un estado que previamente definamos.
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
}
}
Nuestra WaitAction recibe por constructora una variable de tipo Matcher<View>, que aceptará Matchers y ViewMatchers, que usaremos como comprobadores de que se cumple cierta condición sobre el estado de una vista. En el caso de nuestro ProgressBar, que esté visible o no.
El método getConstraints nos permite aplicar restricciones sobre las vistas con las que podrá interactuar nuestra ViewAction. En nuestro caso no necesitamos aplicar ninguna restricción, por lo que permitimos cualquier tipo de vista.
Por otro lado, el método perform contiene la acción concreta a ejecutar sobre la vista. Este método recibe por parámetros un UiController, que nos permite ejecutar eventos sobre la UI de Android, y la vista sobre la que se va a ejecutar la acción. Dentro de este método vamos a desarrollar la acción de esperar hasta que el Matcher compruebe que la vista alcanza el estado que esperamos.
¡Vamos a ver cómo queda!
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()
}
}
Lo primero que hacemos es definir un periodo de tiempo máximo para que la vista alcance el estado que esperamos. Como se puede ver en el bucle while siguiente, nuestra WaitAction comprueba, a través del Matcher que enviamos por constructora, que la vista haya alcanzado el estado esperado dentro del tiempo máximo definido.
Al final de cada iteración del bucle, hacemos uso del método loopMainThreadUntilIdle, el cual pospone la ejecución del resto del código hasta que el UI Thread esté ocioso, es decir, sin ninguna tarea pendiente de ejecutar. De esta manera aseguramos que cualquier acción necesaria sobre la UI pendiente de ejecutarse lo haga antes de comprobar que se cumple nuestra condición.
Si la vista alcanza el estado esperado, nuestra WaitAction finalizará o, en caso de que se haya consumido el tiempo máximo de espera, se provocará una excepción que causará que nuestro test falle.
Finalmente, solo queda hacer uso de nuestra WaitAction desde nuestro 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"
)
)
}
. . .
}
Definimos dos WaitAction, una que espera a que el ProgressView esté visible y otra a que deje de estarlo, momento en el que nuestra tarea en segundo plano habrá terminado y en el que ya dispondremos de los datos en nuestro RecyclerView.
Para terminar…
En este post he compartido con vosotros como, haciendo uso de una ViewAction, podemos gestionar en nuestros tests de UI eventos sobre vistas de nuestra aplicación dependientes de tareas en segundo plano.
Me encantaría conocer vuestras experiencias y opiniones. ¡Os animo a compartirlas en la sección de comentarios!