En los dos posts anteriores (tanto en la parte 1 como en la parte 2) hemos hablado de las consideraciones principales que debemos tener presente a la hora de estructurar la aplicación para ser fácilmente testable, aprendimos los conceptos y herramientas principales.

También nos pusimos manos a la obra e implementamos una serie de tests unitarios y de integración utilizando las herramientas y funciones más comunes de Mockito.

Para finalizar esta serie de post, en este último vamos a ver las denominadas pruebas instrumentadas, que es la base para las pruebas de UI.

Además, como ya mencionamos en los posts anteriores, también es una gran herramienta para pruebas End to End y cualquier otro tipo de prueba que requiera trabajar con la aplicación en su conjunto.

Dependencias

Estas son las dependencias que añadiremos al fichero build.gradle del módulo de aplicación para los ejemplos que veremos posteriormente.


androidTestImplementation 'androidx.test:runner:1.1.1'
androidTestImplementation 'androidx.test:rules:1.1.1'
androidTestImplementation 'androidx.test.ext:junit:1.1.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'
androidTestImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:2.0.0'
androidTestImplementation 'org.mockito:mockito-android:2.23.4'

Para poder seguir utilizando Mockito-Kotlin en este tipo de pruebas sobre la Android VM, es necesario añadir la dependencia sobre Mockito-Android.

El resto de dependencias hacen referencia al runner, que en este caso será AndroidJUnit4, y a Espresso, el framework utilizado para las pruebas instrumentadas.Para asegurar la compatibilidad del runner que vamos a utilizar, asegúrate de tener esta configuración añadida en el fichero build.gradle, referenciando el paquete “androidx”, no el support:


android {
   //...
   defaultConfig {
       //...
       testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
   }
   //...
}

% block:blockquote
% items:
% text:Nota: A partir del lanzamiento de Android Jetpack, los diferentes paquetes support han sido refactorizados y englobados bajo el paquete androidx.
% endblock

La pantalla de ejemplo

Vamos a trabajar con una pantalla muy sencilla, representando a la célebre MainActivity.

El layout está compuesto por los siguientes elementos:

Para estructurarlo, vamos a utilizar MVP, compuesto por:

Además, vamos a crear un caso de uso:

Definimos los siguientes requisitos funcionales:

Por simplicidad para este ejemplo, inicializaremos el presenter en el propio onCreate, aunque lo más conveniente es inyectarlo. En cualquier caso, como le otorgamos una visibilidad pública, podremos reemplazarlo para nuestros tests.

Simplificando, las clases quedan así:


interface MainView {
   fun getEditTextValue(): String?
   fun cleanEditText()
   fun getTextViewValue(): String?
   fun setTextViewValue(value: String)
   fun disableButton()
   fun isButtonEnabled(): Boolean
}


class MainActivity : AppCompatActivity(), MainView {
   lateinit var mainPresenter: MainPresenter
   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_main)
       mainPresenter = MainPresenterImpl(this, GetTextUseCaseImpl())
       myButton.setOnClickListener { mainPresenter.onButtonClick() }
   }
   override fun getEditTextValue(): String? = myEditText.text?.toString()
   override fun cleanEditText(){ myEditText.text = null }
   override fun getTextViewValue(): String? = myTextView.text?.toString()
   override fun setTextViewValue(value: String) { myTextView.text = value }
   override fun disableButton() { myButton.isEnabled = false }
   override fun isButtonEnabled(): Boolean = myButton.isEnabled
}


interface MainPresenter {
   fun onButtonClick()
}


class MainPresenterImpl(private val mainView: MainView, private val getTextUseCase: GetTextUseCase) : MainPresenter {
   override fun onButtonClick() {
       val output = getTextUseCase.getText(mainView.getEditTextValue())
       mainView.setTextViewValue(output)
       mainView.cleanEditText()
       mainView.disableButton()
   }
}


interface GetTextUseCase {
   fun getText(input: String? = "no text"): String
}


class GetTextUseCaseImpl : GetTextUseCase {
   override fun getText(input: String?): String = "This is the UC result for '$input'"
}

Creando la clase de Test

El esqueleto de una clase de Test para pruebas instrumentadas es muy similar al que vimos para los tests unitarios en nuestro segundo post.

De nuevo, contamos con las anotaciones @Before, @Test y @After, y seguimos pudiendo crear e inyectar mocks allá donde necesitemos.

Sin embargo, en esta ocasión necesitaremos especificar el Runner específico para este tipo de pruebas utilizando una anotación de clase @RunWith y una anotación de campo @Rule que nos permita definir la Actividad con la que vamos a trabajar:


@RunWith(AndroidJUnit4::class)
class MainActivityUiTest {
   @Rule
   @JvmField
   var mActivityTestRule : ActivityTestRule = ActivityTestRule(MainActivity::class.java, true, false)
   @Before
   fun setUp(){
       val intent = Intent()
       //Customize intent if needed (maybesome extras?)
       mActivityTestRule.launchActivity(intent)
   }
   @Test
   fun someTest(){
       //...
   }
}

Existen modos alternativos de estructurar una prueba instrumentada y, de hecho, la que se crea por defecto al crear un nuevo proyecto es algo diferente. No obstante, esta que os presento es la manera descrita en la documentación oficial en lo referente a tests de UI.

La ActivityTestRule que hemos definido tiene 3 parámetros:

Es posible que este último parámetro queráis marcarlo a true. Para el ejemplo, he creído más conveniente mostraros cómo lanzar manualmente la actividad en el setUp(), de manera que podamos utilizar un intent customizado.

Esto nos permite añadir “extras” y simular distintos escenarios dependiendo de los datos de entrada de la actividad, cuando su comportamiento dependa de ellos.

¿Y cuando queremos probar un Fragment?

En ese caso, deberéis lanzar de la misma manera la Actividad que lo contiene. Suponiendo que el Fragment sea cargado al inicio de la Activity, ya estáis listos.

Si por el contrario, necesitáis cargar un Fragment distinto, la manera de llegar a él dependerá de vuestra arquitectura de navegación. Podéis ejecutar manualmente esta navegación o quizás condicionar el Fragment cargado en la actividad inicialmente en función del Bundle recibido en el Intent, que ya hemos visto que es algo que podemos editar para las pruebas.

En cualquier caso, tened presente que podéis operar con la Actividad todo lo que necesitéis para preparar el escenario antes de ejecutar el test, aunque esto implique realizar un paso previo de navegación.

Controlando los datos

A la hora de trabajar con tests de UI, es conveniente aislar la capa de presentación del resto de capas, que no son realmente las que se están poniendo a prueba.

Estos tests los podemos ver como tests de integración entre la vista y su presentador o cualquier otra clase controladora, como por ejemplo el ViewModel si utilizais los architecture components de Android JetPack.

En cualquier caso y para el ejemplo que nos atañe, sería suficiente con mockear el caso de uso que hace de puente entre el Presenter y la capa de dominio, y mockear cómo se devuelven los datos.

Como ya mencionamos, el Presenter con el que trabaja la Activity, a pesar de no estar inyectado, es accesible, así que tenemos la posibilidad de sustituirlo en el setUp, justo tras lanzar la Activity.


@RunWith(AndroidJUnit4::class)
class MainActivityUiTest {
   @Rule
   @JvmField
   var mActivityTestRule : ActivityTestRule = ActivityTestRule(MainActivity::class.java, true, false)
   //Collaborators
   lateinit var getTextUseCase: GetTextUseCase
   @Before
   fun setUp(){
       val intent = Intent()
       //Customize intent if needed (maybe some extras?)
       mActivityTestRule.launchActivity(intent)
       val activity = mActivityTestRule.activity
       System.setProperty("org.mockito.android.target", activity.getDir("target", Context.MODE_PRIVATE).path) //needed workaround for Mockito
       getTextUseCase = mock()
       whenever(getTextUseCase.getText(any())).thenReturn("This is the UC mock result")
       val mainPresenter: MainPresenter = MainPresenterImpl(activity, getTextUseCase)
       activity.mainPresenter = mainPresenter
   }
   @Test
   fun someTest(){
       //...
   }
}

A partir del ActivityTestRule podemos obtener la instancia de la Actividad que se ha lanzado y, posteriormente, sustituir el Presenter para que este utilice un mock del UseCase.

Ahora, el ámbito del test se limita exclusivamente al comportamiento de la vista y su presentador, y podríamos tanto cambiar el tipo de dato devuelto como producir excepciones y verificar que la pantalla muestra correctamente el error al usuario.

% block:blockquote
% items:
% text:Nota: a día de escritura de este post, operar con la versión de Mockito para la Android VM (ya sea directamente o a través de Mockito-Kotlin) produce un error indicando que debemos setear la propiedad de sistema “org.mockito.android.target”. La issue está dada de alta en el GitHub de Mockito y parece que en próximas versiones esto estará corregido sin necesidad de añadirlo manualmente, pero en cualquier caso con esta línea solucionamos el problema.
% endblock

Cómo interactuar con la vista

La gran diferencia entre cómo trabajamos una función para un test unitario y cómo lo hacemos para un test de UI viene ahora.

onView(...).perform/check(...)

En los tests de UI necesitamos primero identificar la vista sobre la que vamos a operar (primer bloque) y, posteriormente, interactuar con ella (segundo bloque), ya sea ejecutando una acción sobre ella o simplemente verificando su estado.

Aunque podemos encontrarnos con escenarios más complejos, casi siempre trabajaremos con estos dos bloques.

Por ejemplo, supongamos que queremos realizar un clic sobre el botón, para posteriormente verificar otros estados de la vista. Podríamos conseguirlo de la siguiente manera:


@Test
fun someTest(){
   onView(withId(R.id.myButton))   //first block
       .perform((click()))         //second block
}

El primer bloque devuelve un objeto ViewInteraction, y espera como parámetro un Matcher. En el ejemplo usamos withId (búsqueda por Id), pero existen muchos otros, como withText, que permite buscar una vista por su texto, ya sea por el ID de recurso del String o por el propio String.

Los Matcher se pueden combinar de dos en dos y usarlos en cascada, de modo que busque la vista que cumpla todos ellos:


onView(
   Matchers.allOf(
       ViewMatchers.withText(R.string.textResId),
       Matchers.allOf(
           ViewMatchers.isDescendantOfA(ViewMatchers.withId(R.id.ascendantId)),
           ViewMatchers.isDisplayed()
       )
   )
)

Cuando lo que queremos es validar una condición sobre esta vista, cambiamos el segundo bloque, utilizando ahora la función “check(...)”, que espera un ViewAssertion como parámetro. Es muy fácil crear uno a través de los Matcher de la siguiente manera.


onView(ViewMatchers.withId(R.id.viewId))
   .check(ViewAssertions.matches(ViewMatchers.isDisplayed()))

Primer test

¡Manos a la obra!

Vamos a crear un test que verifique que cuando se pulsa el Button, el texto devuelto por el UseCase se muestre en el TextView.

Además, sabemos que el texto debe ser el que hemos mockeado en el SetUp: “This is the UC mock result”.


@Test
fun whenButtonIsClickedTheUseCaseTextIsShown(){
   onView(withId(R.id.myButton)).perform((click()))
   onView(withId(R.id.myTextView)).check(ViewAssertions.matches(withText("This is the UC mock result")))
}

Como vemos, el Matcher “withText”, al igual que cualquier otro, puede ser utilizado en cualquier de los dos bloques, ya sea para identificar una vista o para realizar una comprobación sobre ella.

Al ejecutarlo, nos pedirá que seleccionemos el dispositivo (ya sea real o emulado) y veremos en vivo y en directo cómo se realizan las operaciones que hayamos configurado en cada test. Además, se percibe como la Activity se relanza para cada test sin conservar ningún estado de la ejecución anterior.

Como no podía ser de otro modo, el test pasa correctamente. Vamos a crear algunos más:


@Test
fun whenButtonIsClickedTheEditTextIsCleaned(){
   onView(withId(R.id.myButton)).perform((click()))
   onView(withId(R.id.myEditText)).check(ViewAssertions.matches(withText("")))
}
@Test
fun whenButtonIsClickedItIsDisabled(){
   onView(withId(R.id.myButton)).perform((click()))
   onView(withId(R.id.myButton)).check(ViewAssertions.matches(not(isEnabled())))
}

El primero de ellos comprueba que el EditText esté vacío y el segundo que el botón no esté habilitado. La notación es bastante descriptiva y creo que no requiere mucha explicación.

Vamos ahora a mezclar las potencias de Espresso y Mockito, de manera que verifiquemos que además del comportamiento gráfico, este es resultado de estar invocando correctamente al UseCase (y no que, por ejemplo, el presenter está pintando un valor hardcodeado).


@Test
fun whenButtonIsClickedUseCaseIsCalledWithTextFromEditText(){
   onView(withId(R.id.myEditText)).perform(replaceText("Test text"))
   onView(withId(R.id.myButton)).perform((click()))
   val captor: KArgumentCaptor = argumentCaptor()
   verify(getTextUseCase).getText(captor.capture())
   assertEquals(captor.firstValue, "Test text")
}

Podemos verificar también que los estados iniciales que marcamos en los requisitos se cumplan nada más arrancar la Actividad, o cómo tratamos una posible excepción lanzada desde el UseCase, pero como no requiere de ningún elemento que no hayamos visto ya en este o en alguno de los posts anteriores, permitidme que lo pasemos de largo.

A nivel gráfico, nuestro ejemplo es tan sencillo que no hay mucho más que probar, aunque por supuesto, una vista más compleja puede requerir de algunas herramientas adicionales.

Vistas relacionadas

A veces necesitamos verificar que una vista sobre la que vamos a operar está relacionada con otra vista, como por ejemplo un Toolbar o un Diálogo.

Esto resulta especialmente útil si no conocemos el ID o simplemente preferimos mantener una mentalidad de caja negra “pura” y realizamos todas las búsquedas por su valor y no por su identificador. Veamos dos ejemplos.

Primero, imaginemos que queremos verificar que el texto “Detail” de un toolbar se está mostrando, para comprobar por ejemplo que hemos navegado a una nueva pantalla.

Para ello, en el siguiente ejemplo añadimos un Matcher indicando que la vista con la que vamos a trabajar tiene que ser descendiente de otra vista con identificador R.id.toolbar:


@Test
fun thisIsATest() {
   //perform some operation over some view...
   onView(
       Matchers.allOf(
           ViewMatchers.withText("Detail"),
           ViewMatchers.isDescendantOfA(ViewMatchers.withId(R.id.toolbar))
       )
   ).check(ViewAssertions.matches(isDisplayed()))
}

Segundo, supongamos que queremos verificar que ahora se está mostrando un texto “This is a Dialog” en una vista cuya raíz es un diálogo. Podríamos tener algo así:


@Test
fun thisIsATest() {
   onView(ViewMatchers.withText("This is a Dialog"))
       .inRoot(RootMatchers.isDialog())
       .check(ViewAssertions.matches(isDisplayed()))
}

En este último caso, cambiamos el scope de la verificación a la raíz de la vista con el texto “This is a Dialog” (función inRoot), indicando además que debe ser un Diálogo.

Si el check comprueba que efectivamente se está mostrando, quiere decir que ese diálogo con ese texto en alguno de sus elementos está presente en pantalla.

Existen una gran variedad de casos, pero como se trata de un post de introducción, ¡te animo a que tires de documentación y pruebes otras variantes tú mismo!

Tests de navegación

Este tipo de tests son perfectamente válidos para verificar que ante diferentes condiciones, la aplicación realiza una navegación correcta, tanto a la hora de avanzar como a la de retroceder.

Al fin y al cabo, contamos con una versión completa de la aplicación instalada en el dispositivo y podemos interactuar con ella tanto como queramos.

Siguiendo el principio de caja negra, un enfoque bastante aceptado a la hora de verificar que se ha navegado a la vista adecuada no es comprobar la Actividad o Fragmento actual, sino verificar alguno de sus elementos visuales (por ejemplo el título del toolbar), por lo que con las herramientas que ya conocemos podemos realizar este tipo de comprobaciones.

Tests End to End (E2E)

En el ejemplo que venimos utilizando, aprovechábamos la función setUp para sustituir el Presenter y mockear el UseCase. Esto creaba un escenario controlado en el que únicamente la Vista y el Presenter estaban a prueba, sin involucrar al resto de clases y capas que participarían en la aplicación real.

Bien, pues si así lo deseamos, podemos no crear ningún tipo de mock sobre ningún elemento y lanzar la aplicación con un Intent idéntico al que se utilizaría en el escenario real, sin realizar ninguna otra modificación.

En ese caso, ya estaríamos trabajando sobre la aplicación real, incluso con las llamadas de red que consumen los correspondientes servicios web, poniendo a prueba el conjunto del sistema.

En definitiva, podemos imaginarlo como si un integrante el equipo instalase manualmente la aplicación, la lanzase y empezase a interactuar con ella para comprobar que todo está bien, solo que en este caso el proceso se realiza de manera automatizada. Como ya te imaginarás, el tiempo que ahorramos a lo largo del proyecto es inmenso.

Podemos incluso aprovechar estos tests para sacar capturas de pantalla y grabar vídeos que nos permitan posteriormente, de un vistazo rápido, comprobar que no hay descuadres indeseados, especialmente cuando estos tests lo ejecutamos en varios dispositivos.

Si no lo conoces, te resultará interesante saber que Android Studio cuenta con una herramienta muy útil para grabar tests de Espresso (Run > Record Espresso Test) detectando las interacciones que realizamos sobre un dispositivo de pruebas y generando automáticamente una función de tests con todas ellas.

Las verificaciones que queramos realizar las tendremos que añadir a mano en el punto de la función correcto, pero tendremos un buen esqueleto para pruebas de “usuario simulado”, en la que puede que queramos realizar una navegación muy compleja por la aplicación.

Simplificando la nomenclatura con Kotlin

Si bien es cierto que la nomenclatura de las diferentes funciones de Espresso es por sí misma muy sencilla de entender, también es cierto que en vistas más complejas podemos llegar a tener funciones con demasiado “boilerplate”, cuando en realidad los elementos claves son muy pocos.

Esto se acentúa para tests de navegación o el resto de ejemplos que hemos mencionado en el punto anterior, en el que puede que se quieran simular decenas de interacciones en una sola función.

Gracias a Kotlin y, concretamente, a una mezcla de sus tipos de funciones infix y de extensión, en el último proyecto en el que he trabajado definimos unas funciones que simplificaban bastante la lectura de este tipo de tests.

Creo que pueden llegar a ser muy útiles, así que permitidme que os muestra algunos ejemplos para que seáis capaces por vosotros mismos de crear todas las que necesitéis.

Funciones de utilidad:


infix fun Int.perform(action: ViewAction) {
   onView(ViewMatchers.withId(this)).perform(action)
}
infix fun Int.checkThat(matcher: Matcher) {
   onView(ViewMatchers.withId(this)).check(ViewAssertions.matches(matcher))
}
infix fun Int.checkThatTextIs(text: String) {
   onView(ViewMatchers.withId(this)).check(ViewAssertions.matches(withText(text)))
}
infix fun Int.replaceTextWith(text: String?) {
   onView(ViewMatchers.withId(this)).perform(ViewActions.replaceText(text))
}

Refactorizando los tests (también importamos .R.id.*), nos queda algo así:


@Test
fun whenButtonIsClickedTheUseCaseTextIsShown() {
   myButton perform click()
   myTextView checkThatTextIs "This is the UC mock result"
}
@Test
fun whenButtonIsClickedTheEditTextIsCleaned() {
   myButton perform click()
   myEditText checkThatTextIs ""
}
@Test
fun whenButtonIsClickedItIsDisabled() {
   myButton perform click()
   myButton checkThat not(isEnabled())
}
@Test
fun whenButtonIsClickedUseCaseIsCalledWithTextFromEditText() {
   myEditText replaceTextWith "Test text"
   myButton perform click()
   val captor: KArgumentCaptor = argumentCaptor()
   verify(getTextUseCase).getText(captor.capture())
   assertEquals(captor.firstValue, "Test text")
}

Puedes crear tantas funciones de este tipo como quieras, en función del tipo de operaciones más habituales en tus tests. Al final, te quedará algo tan simplificado y fácil de leer como hemos visto.

Gestión de estados e hilos

Vamos a intentar responder a la siguiente pregunta, ¿cómo funcionan realmente las pruebas instrumentadas?

Lo cierto es que cuando estamos ejecutando una de estas pruebas, lo que realmente está ocurriendo es que la aplicación de prueba se está instalando junto con otra aplicación adicional, que es la encargada de ejecutar tu app y operar sobre ella para correr los tests (llamémosla app controladora).

Esto es una consideración muy importante, dado que indica que tenemos una aplicación (app de prueba) con su propio Main Thread, y la app controladora, con otro Main Thread diferente.

Cuando a través de Espresso realizamos una operación cualquiera sobre una vista, hay dos puntos a tener en cuenta:

Respecto al primer punto, hay ciertos elementos que pueden llevar a la aplicación de pruebas a no alcanzar nunca este estado, como las animaciones (por ejemplo un ProgressBar rotando sin parar esperando a que algo ocurra).

Esto puede llevar a que el test se quede bloqueado en cierto punto y termine fallando. Por lo que yo personalmente he visto, este tipo de bloqueos varían incluso dependiendo de la versión de Android con la que estemos probando.

Por eso, desde la documentación de Espresso se nos aconseja desactivar las animaciones del dispositivo que utilicemos para lanzar las pruebas.

Existe algunas propuestas automatizadas para desactivar las animaciones antes de ejecutar un text (configuraciones de Gradle o ciertas Rules) que a día de hoy no terminan de dar los resultados esperados en todos los dispositivos, y lo cierto es que la única solución “infalible” es seguir el consejo de la documentación oficial y desactivar a mano las animaciones.

Respecto al segundo punto, al ser dos procesos corriendo de manera asíncrona, es habitual encontrarse que Espresso realice una comprobación sobre una vista sin que haya dado tiempo a que una acción haya terminado de ejecutarse en la aplicación de pruebas.

Siguiendo el ejemplo, digamos que al clicar el botón se invoca al UseCase, que corre cierto proceso en background. Cuando este proceso termina se recibe la respuesta y el TextView pasa a mostrar el resultado. Una vez finalizado este flujo, el botón se deshabilita activa.

Para el test, realizamos un “perform click” sobre el botón e inmediatamente después verificamos que no está habilitado. Es muy posible que el test falle porque a la hora de comprobar esta condición, el botón aún no ha sido deshabilitado.

Para intentar controlar este asincronismo existen en Espresso los IdleResources, que permiten configurar hasta cuándo se debe esperar para continuar con las posteriores acciones y verificaciones.

Existen también otros patrones basados en reintentos con timeout o incluso añadiendo gestiones custom en la arquitectura.

En cualquier caso, como se trata de un post de introducción, creo que por el momento es suficiente con entender esto y estar al tanto de estos posibles fallos o aparentes incoherencias. Si te ves en la coyuntura, ya sabes por dónde empezar a buscar.

¡Preparados, listos, ya!

A estas alturas podemos decir que ya eres todo un iniciado en esto del testing orientado a aplicaciones Android, con conocimientos que van desde el diseño inicial de la arquitectura de la aplicación hasta el desarrollo de tests unitarios, de integración, de UI y E2E.

Si realmente has podido poner a prueba todos estos conceptos y herramientas, que como hemos visto en realidad no son tantos, te aseguro que estás más que listo para afrontar la gran mayoría de tests que vas a necesitar en cualquiera de tus desarrollos.

¡Ahora depende de ti poner en práctica estos conocimientos, ampliarlos, y convertirte en todo un pro! ¡Ánimo!

Cuéntanos qué te parece.

Los comentarios serán moderados. Serán visibles si aportan un argumento constructivo. Si no estás de acuerdo con algún punto, por favor, muestra tus opiniones de manera educada.

Suscríbete

Estamos comprometidos.

Tecnología, personas e impacto positivo.