Testing en Android: haz tus tests de forma rápida y sencilla

Si te dedicas al desarrollo de aplicaciones móviles, seguro que no te extrañará si te digo que durante años he conocido a decenas de buenos programadores que, sin embargo, han reparado poco (o nada) en el testing de sus apps. No os voy a engañar, ¡yo he sido durante mucho tiempo uno de ellos!

Quizás porque suelen ser “proyectos relámpago” y los tiempos son tan apretados que no permiten mirar más allá de “pintar pantallas” lo antes posible, o quizás porque la posibilidad de hacer pruebas manuales está tan en la palma de la mano (en este caso es literal) que nos ha hecho pensar que los tests son una especie de lujo innecesario…

Sea como fuere, la realidad es que encontrar aplicaciones con una buena base de tests no es tan habitual como debería en un entorno de desarrollo profesional.

El objetivo de este post es realizar una rápida introducción a la implementación de tests orientados a aplicaciones Android, para que aquellos compañeros con ganas de salir de ese grupo y dar un paso más allá tengan una pequeña guía inicial.

Como esto del testing puede ser tan complejo como se quiera, lo plantearemos de la manera más simplificada posible, de desarrollador a desarrollador, sin entrar en muchos formalismos y definiciones, y dividido en tres posts.

En este primer post haremos una introducción y empezaremos revisando algunos conceptos esenciales y en posts posteriores entraremos en el universo de los tests unitarios (nos permitirán fácilmente entender y afrontar otros tipos de tests, como los tests de integración) y en los tests de interfaz (orientados al comportamiento de la UI).

Como veremos más adelante, en el caso de aplicaciones Android, los tests de interfaz se realizan a través de las denominadas “pruebas instrumentadas”, las cuales requieren de un dispositivo emulado o real.

Por sus características, estas pruebas son también válidas para tests de integración más completos (desde la interfaz hasta la capa de acceso a datos) y, en especial, para los tests End to End (E2E), dado que podemos hacer pruebas sobre una versión completa de la aplicación, incluso consumiendo los servicios reales, y hacer de manera muy sencilla pruebas que involucren a todo el sistema.

Pero, ¿merece la pena?

Todos hemos heredado proyectos en algún momento y, a la hora de desarrollar alguna nueva funcionalidad sin el conocimiento completo del comportamiento de la aplicación y sus entresijos, hemos metido mano con el miedo de “romper algo”.

Para mí, esa es la razón fundamental para realizar tests. No es sólo para la tranquilidad de tus sucesores, sino la tuya propia según el proyecto evoluciona y empiezas a olvidar los detalles de tus implementaciones anteriores.

Los beneficios del testing están ampliamente documentados y son comunes a todo desarrollo software, por lo que no entraremos en detalle, pero aquí te listo algunos que para mí destacan en el mundo de las aplicaciones móviles:

  • Te obligas a entender la funcionalidad al detalle: incluso cuando creemos conocerla, por el mero hecho de escribir unos tests siempre terminamos descubriendo que había algún escenario que no se había definido completamente.
  • Los cambios de entorno dejan de “dar miedo”: para esto veremos que los tests E2E son clave.
  • Te ahorras horas y horas de trabajo: aunque al principio parece costoso, a medio plazo eres capaz de probar decenas, cientos o incluso miles de escenarios, tanto en un entorno controlado como consumiendo los servicios reales…
  • En unos pocos minutos. Además, no dependerás de la ayuda de compañeros de otras capas del sistema para simular estos escenarios.
  • Puedes aprovecharlos para extraer automáticamente información gráfica actualizada, como capturas de pantalla o videos navegando por la aplicación.
  • Puedes hacer pruebas de estabilidad y rendimiento muy completas.

¿Qué debes saber antes de empezar?

A continuación, vamos a ver una serie de conceptos básicos y consideraciones muy importantes para poder afrontar con facilidad el desarrollo de tests de calidad y que realmente aporten valor a tu aplicación.

Los Mocks

Simplificando, un mock no es más que una implementación “vacía” (esto lo aclararemos) de una interfaz cuyas entradas y salidas podemos controlar a nuestro antojo, independientemente de la implementación real que le hayamos dado en nuestra aplicación.

Los mocks nos permiten contar el número de interacciones con un método/función, verificar el tipo de entrada o devolver cualquier tipo de dato, entre muchas otras cosas.

Si bien nosotros mismos podemos crear “a mano” nuestros mocks, los frameworks como Mockito nos permiten crear potentes mocks muy fácilmente a partir de interfaces, clases abstractas o incluso a partir de clases no finales.

En este último caso con ciertas limitaciones (sólo podrá operar con los métodos que puedan sobrescribirse). Los métodos estáticos tampoco podrán controlarse.

Existen librerías adicionales como PowerMock, que nos permite meter mano a los métodos estáticos, pero en general es recomendable evitarlo y definir una arquitectura que no requiera de ello, pues es habitual que esto nos lleve a ciertos conflictos entre librerías.

Aunque entraremos más en detalle más adelante, con el fin de que puedas entender mejor las secciones posteriores, te adelanto que una manera de inicializar un mock con Kotlin y mockito-kotlin (una librería con funciones de utilidad para Kotlin sobre Mockito), es la siguiente:

val interfaceName: InterfaceName = mock()

Sí, resulta así de fácil.

La arquitectura

Una de las principales dificultades a la hora de incluir tests en una aplicación no son los tests en sí, sino la arquitectura de la misma. Es esencial que esta tenga una estructura lo suficientemente desacoplada como para permitirnos controlar todo el contexto del test. Veamos un ejemplo:

Supongamos que tenemos tres capas formadas por cinco clases colaborando entre sí:

  • Capa de presentación
    • UserDetailPresenter: presenter de una vista que muestra los datos detallados de un usuario.
  • Capa de dominio
    • GetUserUseCase: caso de uso que permite exclusivamente obtener los datos de un usuario a partir de su ID.
  • Capa de datos
    • UserRepository: clase que permite operar con los datos de un usuario, independientemente de su procedencia. Podemos tanto obtener como editar dichos datos.
    • UserApi: clase que permite recuperar o modificar los datos de un usuario a través de Servicios Web.
    • UserDao: clase que permite recuperar o modificar los datos de un usuario de una Base de Datos local.

Para este ejemplo, el comportamiento deseado es el siguiente:

  1. El presenter UserDetailPresenter solicita los datos del usuario a través del caso de uso GetUserUseCase.
  2. El caso de uso, a través de la clase UserRepository, obtiene los datos del usuario y los devuelve al presenter.
  3. La clase UserRepository intenta obtener los datos almacenados localmente a través de la clase UserDao y, si estos no están disponibles, los obtiene del Servicio Web a través de la clase UserApi. En este último caso, antes de devolver los datos, los almacena a través de la clase UserDao para agilizar futuras consultas.

Bien, digamos que queremos probar que la lógica de la clase UserRepository está bien implementada. Es importante remarcar que:

  • Lo que estamos probando es exclusivamente la clase UserRepository, no todo el flujo.
  • El resto de clases no deben intervenir en absoluto en la prueba.

Ahora, imaginemos que nuestra clase UserRepository se parece a esto, teniendo en cuenta que vamos a simplificar las cosas todo lo posible, sin contemplar optimizaciones, errores ni threads:

class UserRepository {

   private val userDao: UserDao = UserDao()
   private val userApi: UserApi = UserApi()

   fun getUser(id: Int): User{
       userDao.getUser(id)?.let { user ->
           return user
       } ?: run{
           val user = userApi.getUser(id)
           userDao.storeUser(user)
           return user
       }
   }

   fun updateUser(user: User){
       userApi.updateUser(user)
       userDao.storeUser(user)
   }

}

Suponiendo que esté bien implementado, la funcionalidad deseada se cumple, no tenemos forma de controlar todo el contexto del test.

Es cierto que podríamos crear un test que invocase el método getUser(id) y verificar que se nos devuelve un usuario, pero como seguro que ya te estas imaginando, esto tiene las siguientes deficiencias:

  • No podemos saber si se ha intentado recuperar el usuario desde BD o desde el WS.
  • En este segundo caso, no podemos saber si se ha almacenado el usuario para futuras consultas.
  • No sabemos si el Usuario devuelto es realmente el usuario obtenido desde una de estas fuentes.
  • En caso de error, no podemos saber si el error es de la clase UserRepository, de la clase UserDao, de la clase UserApi o simplemente de alguna otra capa adicional (con toda seguridad, dependerás de más clases para hacer la llamada al WS o a la BD).

Con el fin de cumplir los objetivos, necesitamos aislar el método y controlar tanto la entrada como las salidas de las clases colaboradoras (UserDao y UserApi).

No sólo eso, sino que necesitamos controlar cómo interactúa la clase UserRepository con ellas, verificando tanto los parámetros como el número y orden de invocaciones. Si esto se cumple, podremos poner a prueba que el método getUser(id) funciona como exigen los requisitos y (casi) tendremos un test unitario.

Es aquí donde entran en juego los mocks, y una gran herramienta para ello es Mockito, como ya hemos visto. No obstante, aunque crear unos mocks es muy sencillo, tal como se ha definido la clase UserRepository no nos es posible “incrustarlos”, o como se conoce coloquialmente, inyectarlos.

Los problemas se nos acumulan: por un lado, no estamos trabajando con interfaces y, por otro, las instancias a las clases colaboradoras se están inicializando en la propia clase UserRepository, por lo que no podemos sustituirlas.

Es por eso que una arquitectura desacoplada, más allá de sus muchas otras ventajas, es esencial para poder implementar tests de una manera sencilla y completa.

Veamos cómo arreglarlo:

  • Debemos definir interfaces entre las diferentes capas, de modo que nuestra implementación de UserRepository invoque a las funciones definidas en dichas interfaces independientemente de qué clases las implemente.
  • Debemos tener un modo de definir qué implementación vamos a usar en esta ejecución de la entidad UserRepository.

Para ello, una solución sencilla es la siguiente:

  1. Las clases UserDetailPresenter, GetUserUseCase, UserRepository, UserDao y UserApi, pasan a ser interfaces.
  2. Estas interfaces las implementamos en su correspondientes clases, las cuales podemos nombrar por ejemplo añadiendo el sufijo “Impl”. Nos quedaría algo así:

Interfaz:

interface UserApi {
   fun getUser(id: Int): User
   fun updateUser(user: User)
}

Implementación:

class UserApiImpl: UserApi {
   override fun getUser(id: Int) = User(id)
   override fun updateUser(user: User) {} //empty for the example
}

  1. Las implementaciones específicas que usa la clase UserRepositoryImpl no deben inicializarse en la propia clase, sino que deben estar inyectadas.
    • Existen librerías bien conocidas a tal efecto, como Dagger 2, y recomiendo utilizarla dado que se ha convertido en una especie de estándar.
    • En cualquier caso, el concepto de inyección es un patrón de diseño independiente de librerías, y nos es suficiente con garantizar que la clase en cuestión recibe las interfaces ya inicializadas desde fuera, por ejemplo a través de su constructor.

class UserRepositoryImpl(private val userDao: UserDao, private val userApi: UserApi): UserRepository {

   override fun getUser(): User{
       userDao.getUser()?.let { user ->
           return user
       } ?: run{
           val user = userApi.getUser()
           userDao.storeUser(user)
           return user
       }
   }

   override fun updateUser(user: User){
       userApi.updateUser(user)
       userDao.storeUser(user)
   }

}

Ahora, en nuestro test podemos inicializar la clase UserRepositoryImpl a la que inyectamos nuestros mocks, de la siguiente manera:

@Test
fun exampleTest(){
   val userDao: UserDao = mock()
   val userApi: UserApi = mock()
   val userRepository: UserRepository = UserRepositoryImpl(userDao, userApi)
  
   //You are ready to go!!
}

No es el objetivo de esta sección entrar en detalles sobre cómo implementar el test, pero sí puedo adelantarte que ahora:

  • Podemos mockear (forzar, por decirlo de alguna manera) qué queremos que devuelva la interfaz UserDao cuando se invoque al método getUser(id).
  • Podemos mockear qué queremos que devuelva la interfaz UserApi cuando se invoque al método getUser(id).
  • Podemos verificar cuántas veces y en qué orden se han invocado las funciones getUser(id) o storeUser(user) de las distintas implementaciones.
  • Podemos verificar que el objeto User devuelto es el mismo que hemos mockeado en las implementaciones de las interfaces UserDao y/o UserApi, sin adulterar.

Con todo ello, ahora sí tenemos la capacidad de verificar que el comportamiento implementado es el esperado, por lo que llevando esta filosofía a todas las capas de la aplicación, nuestra arquitectura ya está lista (a falta de algunos retoques que veremos posteriormente) para ponernos manos a la obra.

Threads

Otra consideración importante para preparar nuestra arquitectura es que debemos tener el control sobre los threads que se van a crear y cómo se van a encolar las distintas tareas.

Esto es así porque en el contexto de los test, tendrémos un único thread ejecutor y aquellas operaciones que corran en threads creados dinámicamente lo harán de manera asíncrona, como es de esperar.

No obstante, el test seguirá con su ejecución sin reparar en ese bloque de código asíncrono y el resultado, con toda probabilidad, no representará la realidad.

No te preocupes si estás ligado a RxJava o a cualquier otra librería, dado que es habitual poder definir el ExecutorService (o cualquier otra interfaz de gestión de hilos) que va a gestionar los threads de la aplicación y a encolar las diferentes tareas en ellos.

Otras librerías proveen métodos tanto asíncronos como síncronos, dándote la opción de realizar tu propia gestión.

En cualquier caso, una vez tengamos el control sobre este Executor, lo que debemos hacer en los tests es crear una implementación específica e inyectarla allá donde sea necesaria.

La particularidad de esta implementación es que ejecutará las tareas en el mismo hilo que lo invoque, garantizando que todo el test se ejecute de manera síncrona y que las respuestas que vamos a verificar son las apropiadas.

Veamos un ejemplo de una implementación de un ExecutorService que, sabiendo que es utilizada únicamente a través del método submit, ejecuta la tarea en el mismo thread, tal como queremos:

class TestExecutor: ExecutorService {
   //overriding the other functions with empty body…(never called)

   override fun submit(task: Runnable?): Future<*> {
       task?.run() //just run the runnable block in the same thread, synchronously
       return Mockito.mock(Future::class.java)
   }

¡Vamos a entendernos!

Antes de continuar, permitidme que aclare la nomenclatura que vamos a utilizar en este y en los próximos posts.

Como hemos visto, lo que inicialmente era una clase, se ha convertido ahora en dos: una interfaz y una implementación. Esto ha ocurrido en todas las capas.

Con el fin de no tener que diferenciarlas constantemente, hablaremos de ellas como entidades (desde un punto de vista de diseño), dando siempre por sentado que todas las entidades hablan entre sí a través de sus interfaces y nunca directamente con sus implementaciones.

Además, por simplicidad y dado que vamos a manejar siempre el mismo ejemplo, simplificaremos las referencias a las distintas entidades de la siguiente manera:

  • Cualquier referencia al “Presenter” se referirá a cualquier interacción con la entidad UserDetailPresenter.
  • Cualquier referencia al “UseCase” se referirá a cualquier interacción con la entidad GetUserUseCase.
  • Cualquier referencia al “Repository” se referirá a cualquier interacción con la entidad UserRepository.
  • Cualquier referencia al “DAO” se referirá a cualquier interacción con la entidad UserDao.
  • Cualquier referencia al “API” se referirá a cualquier interacción con la entidad UserApi.

Por último, soy consciente de que el término españolizado “mockear” no es correcto y, por qué no decirlo, suena muy mal, pero es ampliamente utilizado en el día a día en el mundillo, así que dejadme que me tome la licencia de utilizarlo de vez en cuando.

Un test por escenario

Si te estás preguntando cómo es posible probar todo la funcionalidad descrita previamente en un solo test, la respuesta es que no puedes, o al menos no debes.

Como habrás imaginado, verificar este sencillo comportamiento de manera completa requiere en realidad de varias pruebas diferentes con diferentes escenarios, y es conveniente separarlas. Por listar algunas:

  • Puedes verificar que cuando el DAO devuelve un User, el Repository devuelve este mismo User, sin solicitar la información al API.
  • Puedes verificar que cuando el DAO no devuelve un User, la información es solicitada al API, siendo este el mismo User devuelto.
  • Puedes verificar que, en el caso anterior, antes de solicitar la información al API, se intentó recuperar a través del DAO.
  • Puedes verificar que, en el caso anterior, también es invocado el método storeUser(user) del DAO, recibiendo por parámetro el mismo usuario devuelto por el API.
  • Puedes verificar que el comportamiento es correcto cuando ocurren errores en las llamadas al DAO y/o API.
    • Una posible prueba sería verificar que el error es propagado tal cual se genera en cualquiera de estas entidades, sin controlarlo ni envolverlo en Repository, si es el comportamiento que deseamos. Esto serían al menos dos tests diferentes, uno por entidad.
    • En este ejemplo no hemos entrado en los errores, pero es posible que en un caso real un error al recuperar desde el DAO (BD) fuese controlado y registrado mediante un log, pero aún se continuase para intentar obtener la información desde el API (WS), de manera que debería verificarse también que ante un error del DAO, la información es solicitada al API y que la clase Logger ha sido invocada con la información oportuna.

Como ves, en esto de los tests se puede llegar a un nivel de detalle tan amplio como se quiera, y seguro que se os ocurren varios tests más que aplicar a esta sencilla función.

No obstante, también es importante racionalizar a la hora de desarrollar tests, y debemos pensar en el tiempo del que disponemos. Hay que buscar un buen equilibrio.

Desde mi punto de vista, no debemos obviar nunca los tests y las funcionalidades principales deben estar cubiertas, pero podemos continuar sin comportamientos menos relevantes para la funcionalidad, como si la clase Logger se ha invocado con los parámetros correctos, por ejemplo. En cualquier caso, esto dependerá siempre del contexto del proyecto y la opinión del desarrollador.

Una vez listos esta serie de test, podemos tener la tranquilidad de que tanto si nosotros como nuestros compañeros nos vemos en la necesidad de modificar la función, el comportamiento definido previamente sigue cumpliendose de manera intacta… ¡Y si no ya te avisarán los tests!

Test Unitario

Probar un sólo método se divide en realidad en un conjunto de pruebas aisladas que ahora sí podemos llamar Tests Unitarios, pues cada uno prueba un fragmento muy acotado de la funcionalidad para un escenario bien delimitado.

Un concepto a tener presentes a la hora de dividir tests es que este debe estar lo suficientemente aislado como para que, si otra interacción no relevante para este test no se comporta como se espera, no se camuflen los resultados de este test.

Es decir, si un requisito anterior no se ha cumplido, debe estar detectado en otro test específico de ese requisito, de manera que detectemos rápidamente la fuente real del error y no nos lleve a pensar que el problema ocurre en el requisito del test actual.

Si el orden en el que se ejecuta una cierta lógica de negocio está bien definido y es relevante, se pueden secuenciar también los tests siguiendo este mismo orden.

Esto puede resultar útil para procesos muy complejos, de manera que cuando fallan varios tests, sabes que debes corregir el primero que ha fallado y, con suerte, esto corregirá todos los posteriores.

No obstante, por defecto los tests no tienen un orden de ejecución específico y si el aislamiento está lo suficientemente bien definido, con esto será más que suficiente.

Nomenclatura

La nomenclatura es un tema delicado en casi cualquier ámbito de cualquier lenguaje, y como se suele decir, para gustos los colores. En cualquier caso, me gustaría comentar algunas pautas que yo personalmente he encontrado útiles a la hora de trabajar con ellos.

¡El nombre de los tests debe ser muy descriptivo, aunque haga daño a la vista!

Esto lo aprendí de un ponente en un curso de TDD y es algo que comparto. Con una infraestructura profesional, lo habitual es que la ejecución de tests no se limite a tu máquina, sino que se ejecuten también en algún servidor de Integración Continua.

Por ello, cuando un test falla en ese contexto, lo poco que sabes en un inicio es el nombre de la clase y el de la función del test que ha fallado.

Siendo esto así, un nombre de test tipo “getUserTest()” no describe en absoluto qué parte de ese flujo ha fallado. Esto te lleva a tener que escudriñar en detalle el código para detectar el error.

Por eso, y siguiendo el ejemplo que hemos utilizado en varias ocasiones durante este post, un nombre de una función de test que pruebe exclusivamente que cuando el User se recupera desde el API, este es también almacenado localmente a través del DAO, podría tener un nombre del tipo “ifUserIsRecoveredFromApiThenUserIsStoredThroughDao”.

Sé que no es a lo que estamos acostumbrados, pero esto no es código productivo, sino código diseñado expresamente para detectar errores de la manera más rápida y eficiente posible.

Es por ello que, si este test falla, sin necesidad de recurrir al código sabemos que el error específico es que no se está almacenando localmente el User cuando se recupera del WS y anticipar consecuencias de una manera inmediata.

Actualización de los tests

Existe la posibilidad de que, por necesidades del proyecto, el comportamiento del flujo de recuperación de usuarios haya cambiado, y ahora no se quiera almacenar localmente nunca, de manera que siempre se solicite al WS.

En ese caso, el error ha estado en no actualizar los tests, que deben evolucionar junto con el código productivo. Es conveniente ejecutar los tests de manera habitual localmente mientras desarrollamos para no demorar estas actualizaciones cuando sean necesarias.

Aunque en ocasiones pueda resultar tedioso, lo cierto es que sirve para refrescar qué condiciones deben aun cumplirse tras este cambio de requisitos.

Contexto de ejecución

Un punto importante a tener en cuenta es que los tests unitarios y el resto de tests ejecutados con el runner genérico de JUnit corren sobre la Máquina Virtual de Java, sin acceso completo al framework de Android, aunque sí a una versión reducida.

Esto quiere decir que en tus tests con JUnit podrás referenciar algunas clases de Android, pero no podrás hacer uso de ellas y su invocación puede llevarte a errores. Esto hace esencial dedicar algo de tiempo a envolver aquellas clases habituales del API de Android que vayas a necesitar invocar desde tu lógica de negocio, como la clase Log o la Base64.

La ventaja de utilizar wrappers, como una clase Logger custom que a su vez derive a la clase Log de Android, es que puedes añadir control adicional para desactivar las llamadas cuando estas se encuentran en un entorno de test, o puedes devolver valores específicos para tus pruebas.

Este es un ejemplo sencillo:

class Logger {
   companion object {
       @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
       var enabled = true

       fun d(tag: String, message: String){
           if (enabled) Log.d(tag, message)
       }
   }
}

La anotación @VisibleForTesting nos permite garantizar que la variable que desactiva el logging sea modificable exclusivamente desde un Test.

Existen alternativas a esto, de las cuales diría que destacan dos:

  • Parámetro de configuración en Gradle: esto permite que el API de Android, en lugar de lanzar una excepción al ser invocado, devuelva un valor por defecto (0 o null). Esto puede solucionar problemas en algunos tests, pero seguimos sin tener el control de lo que ocurre en estas invocaciones.

android {
   //...
   testOptions {
       unitTests.returnDefaultValues = true
   }
}
  • Robolectric: un framework que, a través de su runner, permite hacer uso del API de Android desde la JVM con un comportamiento lo más próximo posible a una ejecución sobre un dispositivo, aunque de nuevo, nos encontraremos con algunas limitaciones y problemas. Si te interesa saber algo más, tienes mucha documentación al respecto en la red.

Tipos de tests

Ya hemos comentado algunos de los tipos principales de tests que existen, pero el número de tests según su objetivo, el número de elementos que incluyen, el contexto de ejecución y otros matices sigue creciendo.

Como se trata de una introducción, permitidme que los delimite a los tipos más habituales en el mundo de las aplicaciones Android y, aunque hay varias maneras de afrontarlos, os cuente cómo lo hemos hecho en los equipos de trabajo en los que he participado.

Tests Unitarios

De estos ya hemos hablado, y se trata de tests que prueban una lógica de negocio muy acotada dentro de una entidad específica. Estos tests pueden llegar a involucrar más de una función, pero nunca más de una clase.

Como hemos visto, si hay más de una clase involucrada en una funcionalidad (lo que es habitual), debemos mockear dichas clases o al menos tener control suficiente sobre cómo se comportan durante el test.

A la hora de definirlos, una buena manera es pensar en cómo se habría hecho si se hubiese trabajado mediante TDD. De nuevo, os insto a buscar definiciones más formales en la red, pero como resumen, se podría decir que TDD es una metodología de desarrollo consistente en definir las capas de la arquitectura, las interfaces entre ellas y, antes de desarrollar las implementaciones, desarrollar los tests.

Efectivamente, los tests se desarrollan antes que la lógica de negocio de las diferentes capas. Es decir, los tests se desarrollan sin haberse pensado cómo se va a implementar la funcionalidad a nivel interno. Esto permite definir unos tests enfocados en exclusiva a cubrir una funcionalidad, no a cubrir una lógica ya conocida.

Dice la teoría que después, el desarrollador debe realizar la implementación mínima que le permita pasar todos los tests, de manera que simplifique el desarrollo a meramente cumplir estas condiciones definidas por adelantado, sin “adornos”. Esto permite definir mejores tests y un código más simplificado.

TDD tiene sus pros y contras, es más útil cuando intervienen varios desarrolladores y en definitiva creo que es algo más avanzado como para tratarlo en este post. En cualquier caso, hay mucha documentación en la red al respecto, si te animas.

Bien, pues poniéndonos en esta coyuntura, incluso aunque añadamos los tests de una manera posterior al desarrollo, sigue resultando una buena práctica aislarnos todo lo posible de cómo hemos realizado la implementación, tratando a la entidad como una “caja negra” y centrarnos en qué casuísticas o escenarios concretos existen y cómo esperamos que sea la salida como consecuencia.

Tests de Integración

Un test de integración es aquel que realiza un test que involucra a más de una entidad (digamos clases), generalmente de capas diferentes. El objetivo no es probar cada una de ellas, sino cómo colaboran entre sí.

Por poner un ejemplo, digamos que hemos probado que el Repository propaga cualquier excepción sin envolverla. También hemos probado que el DAO genera una excepción al solicitar un usuario si el ID es desconocido (sin definir un tipo de excepción específica).

No obstante, supongamos que, por diseño, se espera ante este escenario que la entidad Repository propague al Presenter una excepción concreta de tipo “UserException”.

En este caso, los tests unitarios individuales que se marcaron por capa eran correctos, pero al unificar la prueba entre las dos capas sale a relucir un requisito global que no se había tenido en cuenta en la entidad DAO, que en este caso es el tipo de excepción específico que debía lanzar.

En general, si un test de integración falla es porque los tests unitarios por capa no estaban completamente definidos, pero precisamente nos sirven para darnos cuenta de esto.

Como ya os imagináis, los test de integración pueden incluir tantas capas como se quieran, hasta el punto de llegar a probar todas las capas de la app en su conjunto. Como esto implicaría al menos en una app Android, probar también la capa gráfica, yo personalmente he encontrado más práctico realizar este tipo de pruebas a través de las pruebas instrumentadas.

Para que pueda seguir considerándose un test de integración y no un tests E2E, como veremos en el siguiente apartado, es importante aislar la prueba del resto de elementos de la plataforma, esto es, no depender de conexiones de red ni llamadas a servicios reales.

Lo más conveniente cuando se quieren realizar pruebas de integración completas dentro de una app es mockear mediante algún mecanismo la respuesta de una llamada de red, de modo que todo el comportamiento queda delimitado exclusivamente a cómo se ha implementado la app y no al estado de otras capas o servicios. Existen librerías a tal uso o puedes directamente interceptar las llamadas y forzar las respuestas.

Test End to End (E2E)

Cuando un test incluye todo el flujo (todas las capas) de un sistema, suele denominarse Test End to End.

Hay quien denomina test E2E a las pruebas extremo a extremo dentro del contexto de la app, lo que correspondería al ejemplo de “pruebas de integración completas” que hemos visto antes, sin incluir dependencias externas.

Esto es una consideración personal y seguro que podría debatirse, pero en el contexto del desarrollo de aplicaciones móviles (o cualquier aplicación “Front”), tiendo a considerar que las pruebas E2E deben incluir al resto de capas del sistema, incluyendo el API y el “Back”.

En definitiva, una prueba E2E equivaldría a ejecutar la app en un dispositivo (emulador o real) apuntando al endpoint real de un entorno en cuestión, y probar una funcionalidad, desde que el usuario interactúa con la UI hasta que el feedback se muestra por pantalla.

Esto no quiere decir que las prueban deban realizarse a mano, dado que las pruebas instrumentadas nos permiten precisamente esto.

Podemos automatizar una secuencia consistente en lanzar una pantalla, rellenar un campo con el identificador del usuario, clicar un botón y comprobar que tras un máximo de 2 segundos, por ejemplo, se ha mostrado por pantalla el resto de datos del usuario.

Esta prueba ha involucrado todas las capas del sistema, incluyendo a aquellas que se encargan de recuperar y servir la información del usuario desde un servidor remoto.

Este tipo de pruebas son ideales para detectar problemas en el conjunto del sistema tal como la experimentaría un usuario, sin entrar en detalles de qué parte ha fallado pero poniéndonos en alerta de que debemos corregir algo en el flujo problemático.

Test de Interfaz (UI)

Estos tests se limitan a verificar que la UI se comporta como se espera ante un escenario dado, desde un punto de vista mucho más enfocado a la experiencia de usuario que al manejo de peticiones y datos.

Por ejemplo, podemos haber probado a lo largo de todas las capas, mediante el conjunto de tests descritos anteriormente, que el usuario se recupera como debe de la fuente correcta. No obstante, puede ser que exista el requisito de que esa información se muestre en un diálogo con un título azul que muestre el nombre del Usuario.

Esto es lo que permite verificar los tests de UI: qué se está mostrando por pantalla como consecuencia de una interacción con el resto del sistema, ya sea a través de una inicialización, un click o cualquier otro trigger. En el ejemplo dado, verificaríamos que el diálogo existe, que tiene un título, que éste es azul y que representa el nombre del usuario que se ha recuperado.

De nuevo, debemos controlar los tiempos y la profundidad de los tests, priorizando aquellas pruebas más relevantes. En este ejemplo, tendría más prioridad probar que los elementos mostrados al usuario son correctos en cuanto al dato y menos prioridad en cuanto al estilo (color).

En los equipos en los que he trabajado, rara vez hemos dedicado tiempo a probar algo relacionado con el estilo, limitándonos a verificar la información y, como mucho, la posición (por ejemplo si se muestra dentro de un diálogo, un menú lateral, un toolbar, un botón, etc.). En cualquier caso, de nuevo esto depende del contexto del proyecto, lo cerrados que estén los diseños y los tiempos que este maneje.

Para realizar este tipo de pruebas es conveniente aislar la capa de presentación del resto del sistema, de manera que no se trate de una prueba E2E, sino más bien de una prueba de integración entre la vista y su presentador.

Para ello, podríamos inyectar en el Presenter un mock del UseCase y así controlar cuándo devolvemos un usuario (y con qué datos), cuándo un error o cuándo simulamos un delay en la respuesta, por ejemplo.

Aun a riesgo de resultar repetitivo, te recuerdo que este tipo de tests se implementan en Android a través de las ya mencionados “pruebas instrumentadas”, sobre un emulador o un dispositivo real (o varios).

Como añadido, podemos utilizar ciertas librerías o servicios para generar capturas de pantallas y/o video de estas pruebas, y lo más interesante probablemente es lanzarlas en diversos dispositivos con diferentes tamaños y versiones de Android.

Esto nos da la capacidad de, sin nuestra intervención, comprobar que nuestra funcionalidad y experiencia gráfica se cumple en todas las versiones y tamaños de pantalla probados y, además, tener a mano esas capturas que nos permitan verificar con un vistazo rápido que no hay ningún descuadre indeseado en alguno de esos dispositivos.

¡Ya estamos listos!

Si bien es cierto que no hemos entrado en mucho detalle en ninguno de los puntos, creo que para alguien que ha decidido dar el paso de iniciarse en el desarrollo de tests para aplicaciones Android, esta guía es suficiente para tomar consciencia de qué elementos intervienen en los tests, qué consideraciones debemos tener presentes en cuanto al diseño de nuestra app, qué herramientas principales tenemos disponibles y cómo podemos afrontar finalmente el desarrollo de tests sin ningún miedo.

Es hora de ponerse manos a la obra… ¡vamos a por los Tests Unitarios!

Foto de jgironda

Soy Android Developer de profesión desde hace unos 5 años, aunque ya trasteaba con el desarrollo de aplicaciones Android por cuenta propia desde sus primeras versiones, cuando aún estaba en la universidad. Me apasiona todo lo que tiene que ver con tecnología y particularmente la robótica. Como aficiones, no me pierdo ningún partido del Real Madrid y me encantan las series.

Ver toda la actividad de Jorge Gironda

3 comentarios

  1. Urko dice:

    Buenas Jorge,
    Realmente muy bueno el post, pero si no te resulta pesado y al ser dirigido a android y teniendo en cuenta que Kotlin todavia no esta muy extendido, podrias poner también el codigo el Java? Creo que llegaria la explicacion a mas gente y para los que no saben kotlin lo entenderian mejor.

    Sigue asi 💪💪

  2. Raúl dice:

    Menuda pasada de post, minucioso, claro… estoy deseando leer el resto. Así da gusto. Muchas gracias!!!

  3. Erándini dice:

    Gracias por el post, en pocos lados se encuentra una explicación de las bases y qué se debe considerar en el desarrollo para poder incluir pruebas.

Escribe un comentario