Android: Arquitectura y calidad con Espresso

La calidad de software y la arquitectura son conceptos que se retroalimentan el uno al otro. Podremos asegurar mejor la calidad de un sistema cuanto mejor este construido. David González nos enseño en este post los principales aspectos de usar una arquitectura como Android components, la cual nos permite poder desarrollar tests automáticos para una detección prematura de posibles problemas. No olvidemos que el coste de resolución de un bug aumenta exponencialmente respecto al tiempo que tarda en ser detectado.

Gracias a las arquitecturas desacopladas, la vista de una aplicación es independiente del vista-modelo y del modelo (en caso de MVVM), por lo que los datos a mostrar pueden ser fácilmente falseados (con la librería Mockito, por ejemplo) para testear la UI.

Para Android, Espresso es uno de los frameworks más conocidos por su sencillez a la hora de realizar acciones y asertar condiciones. Vamos a dar un rápido repaso a Espresso y algunas de sus características.

¿Qué es Espresso?

Espresso es una herramienta open source para realizar tests de interfaz de usuario sobre aplicaciones Android.

Para empezar a usar Espresso tendremos que incluir la siguiente dependencia en el archivo build.gradle de nuestra app:

androidTestImplementation 'androidx.test.espresso:espresso-core:$version'

donde añadiremos la versión de Espresso que queramos, típicamente la más reciente.

Espresso se basa en el uso de diferentes sentencias para reproducir comportamientos sobre la UI:

  • Matchers: buscan en UI el elemento con el que queremos interactuar.
  • Acciones: es obvio que realizan acciones sobre dichos elementos.
  • Asertos: comprueban el estado final. Para que un test pase correctamente, todos los asertos deben ser positivos.

Espresso core

El comportamiento más común en Espresso es combinar matchers, acciones y asertos para reproducir escenarios de usuario y comprobar el estado final. El core de Espresso nos permitirá realizar estas operaciones.

Las sentencias de Espresso tienen la siguiente forma:

  • Para ejecutar una acción: onView(MATCHER).perform(ACCION)
  • Para comprobar un aserto: onView(MATCHER).check(ASERTO)

Veamos un ejemplo. Si tenemos el siguiente layout de una actividad básica de autenticación:

<LinearLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical">

    <EditText
        android:id="@+id/username"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:contentDescription="@string/username_description"
        android:hint="@string/username_hint">

    <EditText
        android:id="@+id/password"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:contentDescription="@string/pwd_description"
        android:hint="@string/pwd_hint">

    <Button
        android:id="@+id/loginButton"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:contentDescription="@string/loginButton_description"
        android:text="@string/loginButton_text"/>

     <TextView
        android:id="@+id/status"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:contentDescription="@string/status_description"
        android:visibility="gone" />
</LinearLayout>

encontramos dos campos de entrada para las credenciales (username y password), un botón para confirmar, y un mensaje de estado inicialmente oculto que nos contará que ha sucedido (el caso habitual de login correcto suele ser que la actividad concluya, pero para este ejemplo dejaremos el resultado de la operación en status para hacerlo mas visible)

El test sobre un login correcto tendría esta forma:

//Introducimos username
onView(withId(R.id.username)).perform(replaceText("userName"))
//Introducimos password
onView(withId(R.id.password)).perform(replaceText("password"))
//Click al botón
onView(withId(R.id.loginButton)).perform(click())

//Asertamos que el mensaje de estado es visible con el texto correcto
onView(withId(R.id.status)).check(matches(withEffectiveVisibility(Visibility.VISIBLE)))
onView(withText(R.string.success)).check(matches(isDisplayed())

Podemos ver que se buscan los EditText por su id (matching), realizándose acciones sobre ellos para acabar asertando sobre el campo de estado.

Espresso intents

¿Qué sucedería si en vez de ver un mensaje de estado, quisiéramos comprobar que con credenciales correctas se lanza un intent para abrir otra actividad a la que se pasa el nombre de usuario como un extra?

Para realizar este tipo de comprobaciones, necesitamos añadir la librería de intents de Espresso al build.gradle:

androidTestImplementation 'androidx.test.espresso:espresso-intents:$version'

El proceso se basa en la captura de intents, de manera que podemos comprobar si contiene lo que nosotros esperamos.

Con nuestro ejemplo sobre autenticación, pongamos que se hace la comunicación entre actividades de la siguiente manera:

val intent = Intent(this, nextActivity::class)
intent.putExtra("ACCOUNT_USERNAME", username)
startActivity(intent)

Con Espresso podríamos comprobar que el intent se lanza y el extra es el correcto. Usaremos intended para asertar sobre las propiedades de los intents.

//Comenzamos a "escuchar" intents
Intents.init()

//Aquí irían las acciones para completar credenciales y clicar el botón
...

//Comprobamos que se produce un intent hacia la actividad esperada
intended(hasComponent(nextActivity::class.name))  
//Comprobamos que el intent capturado tiene el extra que esperamos
intended(hasExtra("ACCOUNT_USERNAME", username))  

//Dejamos de "escuchar" intents
Intents.release()

En el caso de que se lance un startActivityForResult esperando que se nos devuelva un resultado, cambiaríamos un poco nuestra manera de proceder. Dado que solo estamos probando nuestra actividad actual, haremos uso de intending para mockear el resultado que nos devolvería la actividad que se inicia. Pongamos otro ejemplo:

val RETURNED_VALUE_EXPECTED = 1
...
val intent = Intent(this, nextActivity::class)
intent.putExtra("VALUE", value)
startActivityForResult(intent, RETURNED_VALUE_EXPECTED);

Y este sería el código para los tests con los que mockearíamos el resultado que nos devuelve la actividad:

//Comenzamos a "escuchar" intents
Intents.init()  

//Este es el intent que generaría nextActivity de vuelta
val resultNextActivity = Intent() 
resultNextActivity.setAction("ACTION_TO_DO");

//Creamos el ActivityResult
val intentResult = Instrumentation.ActivityResult(RESULT_OK, resultNextActivity) 
intending(hasAction("ACTION_TO_DO")).respondWith(intentResult)

//Asertos
assertThat(...)

//Dejamos de "escuchar" intents
Intents.release() 

Lo primero que se hace es crear una variable Intent, equivalente al que devolvería nextActivity. Dicha variable formara parte de un ActivityResult, que a su vez también contendrá el resultado que se espera. En el momento que suceda el intent contra nextActivity durante la ejecución, intending se encargará de devolver ese resultado que le hemos configurado. A partir de ahí ya pondremos los asertos que nos interesen.

Espresso web

En el caso de que la aplicación levante un webView, podemos realizar acciones con Espresso para interactuar con él. Aquí ya entrarían factores externos como el diseño de la web y el etiquetado de sus elementos, necesarios para poder interactuar.

Al igual que los anteriores, Espresso web necesita su propia librería en build.gradle

androidTestImplementation 'androidx.test.espresso:espresso-web:$version'

Un ejemplo simple de como sería el proceso de autenticación dentro de un webView:

onWebView().withElement(findElement(Locator.NAME, webViewUsernameId)).perform(webKeys(testUser));
onWebView().withElement(findElement(Locator.NAME, webViewPasswordId)).perform(webKeys(testPassword));
onWebView().withElement(findElement(Locator.XPATH, webViewSubmitXPath)).perform(webClick());

Como podemos ver, las principales diferencias son el uso de onWebView en lugar de onView, y el nombre de las acciones. (Mas info aquí)

Espresso contrib

La librería contrib extiende Espresso añadiendo una serie de extras que no están cubiertos por el core. Aquí quedan enumerados:

  • AccessibilityChecks: como su nombre indica, incluye checks de accesibilidad.
  • ActivityResultMatchers: matchers de la librería hamcrest para Instrumentation.ActivityResult.
  • DrawerActions: acciones para interactuar con DrawerLayout.
  • DrawerMatchers: matchers para DrawerLayout.
  • NavigationViewActions: acciones para interactuar con NavigationView.
  • PickerActions: acciones para interactuar con Date/TimePickers.
  • RecyclerViewActions: acciones para interactuar con RecyclerView.
  • ViewPagerActions: acciones para interactuar con ViewPager.

Como acceso rápido para ver de un vistazo todas las principales operaciones con Espresso tenemos la cheat sheet oficial de la versión 2. Aún esperamos la oficial de la versión 3. Una gran ayuda cuando estas realizando los tests.

En resumen, Espresso nos permite interactuar con las vistas de manera sencilla, flexible y versátil. El apoyo de una arquitectura que desacopla las vistas hace que los tests sean puramente de UI, sin tener en cuenta dónde y como se almacenan o traen los datos. Esto es muy importante y relevante porque nos permite hacer una división de responsabilidades también a nivel de tests y no solo de código.

Artículos relacionados:

Deja un comentario

Responsable » Solidgear.
Finalidad » Gestionar los comentarios.
Legitimación » Tu consentimiento.
Destinatarios » Los datos que me facilitas estarán ubicados en los servidores SolidgearGroup dentro de la UE.
Derechos » Podrás ejercer tus derechos, entre otros, a acceder, rectificar, limitar y suprimir tus datos.

¿Necesitas una estimación?

Calcula ahora