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:

¿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í:

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:

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:

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:

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.
  2. 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.
  3. 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:

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:

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:

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:


android {
   //...
   testOptions {
       unitTests.returnDefaultValues = true
   }
}

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!

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.