¿Buscas nuestro logo?
Aquí te dejamos una copia, pero si necesitas más opciones o quieres conocer más, visita nuestra área de marca.
Conoce nuestra marca.¿Buscas nuestro logo?
Aquí te dejamos una copia, pero si necesitas más opciones o quieres conocer más, visita nuestra área de marca.
Conoce nuestra marca.dev
Jorge Gironda 08/04/2019 Cargando comentarios…
Si no has leído la primera parte de “Testing en Android: haz tus tests de forma rápida y sencilla” te recomiendo que le eches un ojo rápido antes de continuar con este, porque mencionaremos algunas cosas que vimos en él.
En esta segunda entrega, veremos los tests unitarios más en detalle. Como ya dijimos, desarrollar tests unitarios y tests de integración es muy similar y la diferencia radica en el número de entidades, capas y /o escenarios involucrados en la funcionalidad que se está probando. Para aclararlo, veremos ejemplos de ambos.
El objetivo de este post es ser mucho menos teórico que en el anterior y mancharnos más las manos, de manera que repasemos rápidamente mediante ejemplos algunas buenas prácticas a la hora de implementar una serie de tests relacionados entre sí y veamos las funciones principales que nos proporcionan JUnit y Mockito, al menos aquellas que hemos encontrado más útiles en los equipos en los que he trabajado.
Por supuesto, existen muchas maneras válidas de afrontar los tests de una funcionalidad y ni mucho menos pretendo dar a entender que esta es la más correcta, pero creo que será un buen punto de partida para que después cada uno de vosotros podáis refinar vuestros propios estilos.
La idea es darte un empujón para empezar, pero está en tus manos indagar más y sacarle aún más partido
Empecemos detectando qué debemos probar. Siguiendo el ejemplo del post de introducción, recordamos que teníamos entre otras clases una interfaz UserRepository y una implementación UserRepositoryImpl.
Interfaz:
interface UserRepository {
fun getUser(id: Int): User
fun updateUser(user: User)
}
Implementación:
class UserRepositoryImpl(private val userDao: UserDao, private val userApi: UserApi): UserRepository {
override fun getUser(id: Int): User{
userDao.getUser(id)?.let { user ->
return user
} ?: run{
val user = userApi.getUser(id)
userDao.storeUser(user)
return user
}
}
override fun updateUser(user: User){
userApi.updateUser(user)
userDao.storeUser(user)
}
}
Vimos que la funcionalidad deseada para la función getUser(id) era la siguiente:
Aunque pueda resultar obvio, déjame recalcar que lo que siempre se prueban son las implementaciones, nunca las interfaces. Por tanto, el sujeto de pruebas en este ejemplo será la clase UserRepositoryImpl y el objetivo será verificar que cumple los requisitos definidos para la entidad UserRepository, representada mediante una interfaz. Durante los ejemplos, nos centraremos exclusivamente en la función getUser(id).
Si te estás preguntando qué pasa en el caso de las clases abstractas, la regla se mantiene. Una clase abstracta no puede ser instanciada, y por tanto no puede ser probada por sí misma.
Debemos probar alguna de sus implementaciones, si bien es cierto que en ese caso es posible que parte de la lógica que estemos probando esté implementada realmente en la clase abstracta. En cualquier caso, quedémonos con la idea: solo probamos implementaciones.
Lo más habitual es crear un fichero de Tests por clase que se quiere probar. Este fichero no es más que otra clase que contiene las referencias necesarias para el conjunto de tests y una serie de funciones de test, representando cada una de ellas un test unitario.
Además, contamos con un conjunto reducido de funciones comunes a todos los tests, como ya veremos.
Hagamos un inciso para aclarar esto. Cuando trabajamos con algunas de las librerías más habituales, como Retrofit, estas funcionan generando automáticamente una implementación a partir de una interfaz que definimos en el proyecto, configurada a partir de una serie de anotaciones.
Bien, pues nunca debemos probar implementaciones de terceros, dado que es algo que ya deben haber realizado ellos antes de publicar la librería. Al no tener una implementación propia que probar, nos ahorramos tener que testar esa parte del código, con su correspondiente ahorro de tiempo.
Un ejemplo de esto sería si hubiésemos definido la interfaz UserApi de la siguiente manera y hubiésemos generado la implementación a partir del builder de Retrofit, como de costumbre:
Interfaz:
interface UserApi {
@GET("users/{user_id}")
fun getUser(@Path("user_id") id: String): Call
}
Inyección:
@Provides
@Singleton
fun provideUserApi(retrofit: Retrofit): UserApi = retrofit.create(UserApi::class.java)
Android Studio cuenta con un wizard de creación de test y él mismo sugiere la nomenclatura más común, que consiste en añadir el sufijo “Test” al nombre de la clase que se va a probar. En este caso, “UserRepositoryImplTest”.
Veréis que hay compañeros que simplifican la nomenclatura a “UserRepositoryTest”, dado que es la entidad que se está probando, pero esto de alguna manera implica que existe una única implementación de la interfaz.
Teniendo en cuenta que lo que realmente se prueba es una implementación específica, yo personalmente prefiero la primera opción. Así, en el caso de llegar a tener más de una implementación, no nos veremos obligados a renombrar ni nos lleva a confusión.
Si utilizas el wizard, verás que por defecto la clase es añadida en el mismo paquete que la clase que vamos a probar, pero con la diferencia de que cuelga del directorio raíz “test”, a la misma altura del directorio “main”.
Además de por coherencia, tener una clase de test en el mismo paquete que la clase productiva permite en Java acceder a variables definidas con visibilidad “package”, lo cual resulta especialmente útil para acceder a ciertas variables del sujeto de pruebas y modificarlas o validarlas en el test.
No obstante, este tipo de visibilidad ha desaparecido en Kotlin y estar en el mismo paquete pasa a ser algo más irrelevante, aunque creo que sigue siendo adecuado para mantener una estructura ordenada del código de testing.
Para los ejemplos que veremos a continuación, estas son las dependencias utilizadas, añadidas como de costumbre al build.gradle del módulo de la aplicación:
dependencies {
//...
testImplementation "junit:junit:4.12"
testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.0.0"
//...
}
Usamos Mockito-Kotlin por comodidad, pero no se trata más que de una librería con una serie de funciones de utilidad que nos evita cierto “boilerplate” a la hora de inicializar mocks con Mockito y operar sobre ellos, gracias a las maravillas de Kotlin.
Es esta librería la que a su vez, mediante dependencia transitiva, importa la librería de Mockito con la versión más conveniente para interoperar entre sí.
En cualquier caso, sois libres de utilizar directamente Mockito sin la intervención de Mockito-Kotlin, ¡aunque permitidme que yo siga haciendo uso de ella!
Las funciones de test se ejecutan en el runner de JUnit siempre y cuando la función cuente con la anotación @Test. Para que una clase de test utilice el runner de JUnit no es necesario añadir nada, dado que es el runner genérico y cualquier test definido en una clase que no indique lo contrario, correrá sobre él.
class SomeClassTest {
@Test
fun thisIsATest() {
assertEquals(4, 2 + 2)
}
@Test
fun thisIsAnotherTest() {
assertEquals(8, 4 + 4)
}
}
Podremos ejecutar los tests de manera individual (por función), por clase (ejecutando todos los tests contenidos en esta) o directamente ejecutar todos los tests del proyecto.
Supongamos ahora que ambos tests quieren verificar algo relacionado con un User, siendo este una clase de este tipo:
data class User(val id: Int = 0, val name: String? = null)
Volviendo al ejemplo, podríamos tener algo así:
class SomeClassTest {
@Test
fun thisIsATest() {
val user = User(1234, "name")
assertEquals(user.id, 1234)
}
@Test
fun thisIsAnotherTest() {
val user = User(1234, "name")
assertEquals(user.name, "name")
}
}
No tengáis en cuenta la nomenclatura de estos tests, ¡ya sé que no estoy siguiendo la recomendación que os dí en la introducción! Se trata de un ejemplo sencillo sin un sentido real y de ahí la licencia, pero prometo ser más coherente con mis palabras en los tests que veremos más adelante.
Como vemos en el ejemplo, tenemos dos tests que operan sobre un usuario similar, si bien son dos instancias diferentes creadas al inicio de cada test. Para evitar esta duplicidad de código, tenemos a mano una función específica que se ejecuta antes de cualquier test, la cual denotamos con la anotación @Before.
class SomeClassTest {
lateinit var user: User
@Before
fun setUp(){
user = User(1234, "name")
}
@Test
fun thisIsATest() {
assertEquals(user.id, 1234)
}
@Test
fun thisIsAnotherTest() {
assertEquals(user.name, "name")
}
}
Esta función es invocada siempre antes de la ejecución de cada test. Es importante prestar atención al matiz “de cada test”. Es decir, esta función no se ejecuta una única vez antes de los tests, sino que antes de ejecutar cada función @Test se vuelve a invocar la función @Before.
Teniendo esto presente, lo cierto es que cada test sigue utilizando una instancia diferente de la clase User, pero reutilizamos código y por extensión nos ahorramos errores y trabajo si la manera de inicializar un usuario varía, dado que de no haber hecho este cambio nos veríamos obligados a cambiarla en cada función de test.
Recordad que es importante que cada test esté aislado del resto, por lo que en contra de lo que nos dice nuestro instinto arácnido, inicializar de nuevo todas las variables partícipes del test antes de su ejecución es algo deseable.
De nuevo, dejadme que os recuerde que esto no es código productivo y no perseguimos un alto rendimiento en los tests, ni optimización de CPU o memoria, sino ejecutar unos tests bien aislados que nos permitan detectar errores lo más acotados posible de una manera eficaz.
Siempre tenemos la capacidad de reutilizar variables añadiendo algo de lógica a esta clase de Test, pues la clase sí se inicializa una única vez.
Por ejemplo, existen patrones más o menos extendidos para manejar flags que indican si ya se ha invocado previamente la función setUp y en consecuencia reinicializar o no ciertas variables. En la práctica, esto no suele resultar necesario, pero tenedlo presente.
Como recomendación, os sugiero que llaméis a esta función siempre “setUp()”, dado que es una especie de regla no escrita entre desarrolladores y es habitual encontrarlo con esa nomenclatura en diferente documentación. Al fin y al cabo, lo que hace es preparar el entorno para la ejecución del siguiente test.
Es en esta función donde habitualmente se inicializan todos los mocks y se define su comportamiento más genérico, de manera que se reutilice en el máximo número de tests. Siempre podremos, posteriormente, modificar cualquier mock en la propia función de test, que es invocada justamente después.
Se trata de justamente lo contrario a @Before. La función marcada con esta anotación será ejecutada justo después de cada función @Test.
La idea de esta función es la de poder liberar recursos o limpiar ciertos estados, si esto es necesario en tu aplicación tras la ejecución de algún test.
En mi experiencia, la verdad es que rara vez la he necesitado, pero sí ha resultado útil en algunos casos muy particulares. A esta función se la suele llamar “tearDown()” y, de nuevo, recomiendo que uséis este nombre siempre que podáis, aunque a la hora de nombrar esta función sí he visto algo más de imaginación.Un ejemplo de uso podría ser el de una variable estática que mantiene un valor utilizado en varios tests. Digamos, por economizar, que la clase User tiene una variable estática (definida en el bloque “companion object” en Kotlin) que indica si ya está logado o no.
class User(val id: Int = 0, val name: String? = null){
companion object {
var isLoggedIn: Boolean = false
}
}
Suponiendo que esta variable estática sea modificada por la ejecución interna de algún test, este método es un buen lugar para resetear su valor.
También tendríamos la opción de limpiarla en la función setUp (@Before) antes de ejecutar cada test, pero si queremos mantener una estructura más ordenada, es posible que prefiramos agrupar este tipo de operaciones en el método tearDown (@After).
class SomeClassTest {
lateinit var user: User
@Before
fun setUp(){
user = User(1234, "name")
// User.isLoggedIn = false
}
@Test
fun thisIsATest() {
assertEquals(user.id, 1234)
}
@Test
fun thisIsAnotherTest() {
assertEquals(user.name, "name")
}
@After
fun tearDown(){
User.isLoggedIn = false
}
}
Para mantener un punto de referencia común, vamos a reutilizar el ejemplo que hemos mencionado al inicio del post y que venimos arrastrando desde el post de introducción.
Vamos a poner a prueba nuestra implementación de la interfaz UserRepository y, como ya hemos visto, esto consiste en crear una clase de test “UserRepositoryImplTest”.
Ya vimos en el post de introducción que la arquitectura de la aplicación era algo fundamental, y es ahora cuando más partido le sacamos. Vamos a ver cómo nos quedaría el esqueleto de este Test:
class UserRepositoryImplTest {
//Test subject
lateinit var userRepository: UserRepository
//Collaborators
lateinit var userApi: UserApi
lateinit var userDao: UserDao
@Before
fun setUp(){
userApi = mock()
userDao = mock()
userRepository = UserRepositoryImpl(userDao, userApi)
}
//... let's do it!
}
En este esqueleto, lo cierto es que los mocks de UserApi y UserDao no han sido configurados para comportarse de una manera específica, y esto tiene como consecuencia que toda función devolverá un valor genérico (0/null) o nada, en el caso de funciones Unit (void en Java).
También vemos que la referencia al Repository la mantenemos únicamente a su interfaz, y no a la implementación. Esto es así porque, si tenemos una arquitectura suficientemente desacoplada, todas las interacciones que hay que probar serán a través de la interfaz y cualquier otra función creada en la implementación que no sea parte de la interfaz debería ser invocada desde alguna de las funciones que sí son parte de esta.
Recordemos que debemos crear los tests desde una mentalidad de operar con una “caja negra”. En cualquier caso, si os veis con la necesidad de probar otra función pública no definida en la interfaz, siempre podéis hacer las pruebas sobre la referencia de la implementación.
Volviendo al ejemplo, el comportamiento que vamos a atribuirle al conjunto de mocks va a variar según la prueba, pero personalmente creo que es buena práctica aplicar la configuración más habitual en el setUp(), aligerando algunas funciones de tests que van a utilizar esta configuración.
En este caso, digamos que queremos empezar probando cómo se comporta la función cuando ambos colaboradores devuelven un User cuando se les solicita, sin errores.
Como ya vimos, hay varias pruebas a realizar solo bajo este escenario, por lo que podemos aplicar esta configuración genérica sabiendo que será reutilizable en un buen número de tests.
Vamos a ello:
class UserRepositoryImplTest {
//Test subject
lateinit var userRepository: UserRepository
//Collaborators
lateinit var userApi: UserApi
lateinit var userDao: UserDao
//Utilities
lateinit var userFromApi: User
lateinit var userFromDao: User
@Before
fun setUp(){
//Mocking UserApi
userApi = mock()
userFromApi = User(0, "fromApi")
whenever(userApi.getUser(any())).thenReturn(userFromApi)
//Mocking UserDao
userDao = mock()
userFromDao = User(1, "fromDao")
whenever(userDao.getUser(any())).thenReturn(userFromDao)
userRepository = UserRepositoryImpl(userDao, userApi)
}
//... let's do it!
}
Veamos que hemos hecho:
Según avancemos iremos haciendo algún alto para comentar en más detalle cómo funcionan los bloques que utilicemos.
Hemos llegado al primer y posiblemente más utilizado bloque de Mockito, en este caso ligeramente adaptado por Mockito-Kotlin.
La nomenclatura de métodos es bastante buena y seguro que ya intuís cómo funciona, pero en cualquier caso veámoslo juntos.
Como primer parámetro para la función “whenever” debemos referenciar una función específica de un mock de Mockito.
Es decir que, si por ejemplo, la variable userApi se hubiese inicializado de una manera ordinaria (en lugar de con la función “mock()”), a la hora de ejecutar el test este lanzaría una excepción, pues la implementación ordinaria no tiene las capacidades necesarias para operar con Mockito.
Este primer bloque indica que siempre que esta función sea invocada, debe realizarse algo, lo cual que indicaremos en el segundo bloque.
Cuando la función a mockear tiene algún parámetro, es importante también indicar qué filtro debe cumplir. Es decir, si en lugar de any(), lo que denota cualquier valor, hubiésemos indicado “2”, el mock solo devolvería este usuario cuando estemos solicitando el usuario con ID con valor 2.
Contamos con varias funciones “any()” predefinidas, como anyString() o anyInt(), cuando queremos delimitar el tipo, pero para objetos más complejos o custom, podemos utilizar la función any() tal como lo estamos haciendo, la cual inferirá el tipo correcto para el parámetro concreto de entrada de la función a mockear.
whenever(userApi.getUser(any())).thenReturn(userFromApi)
Recomiendo utilizar la función any() siempre que el tipo no sea relevante para la prueba. Es decir, en este caso sabemos perfectamente que el ID es de tipo Int, y por tanto la función anyInt() nos funcionaría perfectamente e incluso delimitaríamos el tipo de parámetro que queremos contemplar en nuestro mock.
Sin embargo, pongámonos en el caso en el que el ID pasa a ser de tipo String por un cambio en los requisitos. Más allá de los propios problemas de refactorización del código productivo, nuestros tests también fallarían en tiempo de compilación.
Esto es así porque anyInt() devuelve un entero (aunque se trate de un matcher), y ya no es válido como tipo de parámetro en la función, que ahora es un String. En cambio, utilizando any() y por la inferencia de tipos, el mock seguiría compilando, y simplemente la nueva implementación devuelta por esta función sería de tipo String.
Esta función define el objeto a devolver para la condición configurada en el bloque “whenever(...)”.
El valor devuelto tendrá que coincidir con el tipo que devuelve la función mockeada, o de lo contrario nos avisará el compilador con un error.
Como comentamos previamente (aunque no nos venga bien para estas pruebas concretas), podríamos devolver otro mock y simplificar el código, así que tenedlo presente porque en ocasiones no querréis trabajar con el objeto devuelto y os resultará muy útil:
whenever(userDao.getUser(any())).thenReturn(mock())
Esta función es una alternativa a la función anterior y nos permite trabajar con una respuesta más elaborada, definiendo un bloque de código que como última línea debe contener el objeto a devolver. Por ejemplo:
whenever(userDao.getUser(any())).thenAnswer {
val user = User()
//do something...
user
}
Dentro de este bloque podemos lanzar excepciones, por ejemplo, y resulta bastante útil cuando queremos condicionar la respuesta a una serie de factores más dinámicos.
when
(...).Este es un bloque alternativo al bloque “whenever(...).thenAnswer{...}” visto previamente, con una funcionalidad similar.
El hecho de mencionarlo en este post es para compartir un pequeño truco. En nuestro último proyecto, detectamos en mi equipo de trabajo que a la hora de lanzar excepciones, el bloque habitual para ello no funcionaba correctamente con Kotlin:
whenever(userDao.getUser(any())).thenThrow(Exception())
Sin embargo, este bloque alternativo sí se comportaba como esperábamos:
doAnswer { throw Exception() }.`when`(userDao).getUser(any())
Es posible que cuando leas esto este comportamiento ya no se reproduzca, pero si te ves con este problema aquí tienes una solución que espero que te ahorre unas cuantas horas de “rascado de cabeza”.
Aprovecho esto como un ejemplo más de que existen diferentes estructuras alternativas para generar la misma configuración en el mock, por lo que probablemente no haya unas mejores que otras y podéis elegir aquella que más cómoda os resulte.
Si te estás preguntando el por qué de esas comillas rodeando al “when”, en Kotlin es una palabra reservada para un tipo de bloque y de esta manera le hacemos saber al compilador que nos estamos refiriendo al nombre de un método y no a dicho bloque.
¡Por fin estamos en condiciones de implementar nuestro primer test unitario!
Empecemos verificando un par de requisitos:
Para ellos utilizaremos un escenario en el que el DAO efectivamente devuelve un User. Este escenario está contemplado en la configuración del mock definida en el setUp, por lo que en las funciones de tests nos podemos limitar a aplicar las verificaciones.
class UserRepositoryImplTest {
//Test subject
lateinit var userRepository: UserRepository
//Collaborators
lateinit var userApi: UserApi
lateinit var userDao: UserDao
//Utilities
lateinit var userFromApi: User
lateinit var userFromDao: User
@Before
fun setUp(){
//Mocking UserApi
userApi = mock()
userFromApi = User(0, "fromApi")
whenever(userApi.getUser(any())).thenReturn(userFromApi)
//Mocking UserDao
userDao = mock()
userFromDao = User(1, "fromDao")
whenever(userDao.getUser(any())).thenReturn(userFromDao)
//Test subject initialization
userRepository = UserRepositoryImpl(userDao, userApi)
}
@Test
fun repositoryAsksForUserToDaoWithProperUserId(){
val userId = (0..10).random()
userRepository.getUser(userId)
verify(userDao, times(1)).getUser(userId)
}
@Test
fun ifDaoReturnsUserThenApiIsNotCalled(){
userRepository.getUser(0)
verify(userApi, never()).getUser(any())
}
@Test
fun ifDaoReturnsUserThenRepositoryReturnsSameUser(){
val user = userRepository.getUser(0)
assertEquals(user, userFromDao)
assertEquals(user.id, 1)
assertEquals(user.name, "fromDao")
}
}
¡Los tests pasan correctamente! Vamos a ver qué hemos hecho:
La función “verify” nos permite verificar el número de invocaciones a una función en concreto. De nuevo, el primer parámetro debe corresponder a un mock inicializado con Mockito o nos encontraremos con una excepción.
El segundo parámetro indica el número de veces que debe haber ocurrido esta invocación. La función times(...) es la manera más sencilla de definir este número, pero existen otras funciones predefinidas que pueden sernos de utilidad, especialmente si el número esperado es variable:
Si queremos verificar que se ha invocado una única vez, podemos no pasar este segundo parámetro e internamente se inicializará con el valor “times(1)”.
La palabra hace referencia al nombre de la función, que debe ser una de las invocables en el mock pasado por parámetro anteriormente. Si esta función tiene algún parámetro, como es el caso, este debe coincidir con el parámetro que esperamos o tratarse de algún matcher más genérico, como any().
En este caso, hemos usado un random para verificar que el ID es el correcto y que, además, no ha sido “suerte” que hayamos ido justo a probar un ID que es hardcodeado internamente, pues en tal caso el error saltaría en alguna de las ejecuciones.
La razón por la que hemos dejado de utilizar el random en los siguientes tests y admitimos cualquier ID (con la función any()) es que queremos evitar enmascaramientos.
Si siempre probásemos el requisito del ID correcto, en el hipotético caso de que la implementación del Repository cambiase y ahora se pasase al DAO/API un valor igual a ID+1, entonces fallarían todos los tests, a pesar de no ser el comportamiento que estaban verificando la gran mayoría de ellos.
No obstante, como trataremos un poco más adelante, este aislamiento debe estar racionalizado.
Por otro lado, cuando operamos con estos mocks de Mockito, todas las interacciones sobre ellos quedan registradas para su posterior chequeo. Es por eso que la función “verify” se ejecuta después de invocar la función que queremos validar.
Teniendo esto en cuenta, ahora se hace más patente que es necesario reinicializar los mocks antes de ejecutar cada test (en el setUp, por ejemplo), de manera que estos recuentos de interacciones se restauren para la siguiente prueba.
Las funciones “assert” son una de las herramientas principales a la hora de verificar una condición que consideramos que debe cumplirse para que un test pase.
Existe un gran número de ellas, aunque probablemente la más utilizada es “assertEquals”, que verifica que los dos parámetros son iguales, admitiendo diferentes tipos de parámetros, como vemos en el ejemplo (objetos, String, Int, etc.).
Te animo a que indagues algo más sobre los diferentes tipos de verificación que existen, pero aquí te listo algunos muy comunes, los cuales creo que no requieren de explicación:
Cuando la condición es más compleja, tenemos una función “assertThat” que nos permite definir un matcher para la condición, lo que viene a ser una función de comparación algo más elaborada.
Si te estás preguntando por qué en la segunda función hemos invocado a la función getUser con el ID con valor 0, y sin embargo la validación que comprueba que el ID es 1 es correcta, déjame que te recuerde que cuando “mockeamos” la función, lo hicimos de manera que independientemente del ID pasado por parámetro, esta devolvería un User con el ID con valor 1.
Imagino que puede haberte llevado a confusión, pero lo he considerado un buen ejemplo para practicar con estas pequeñas incoherencias que, en realidad, son muy coherentes.
Es interesante conocer la función fail(), que básicamente provoca un error en el test equivalente a que un “assert” no se cumpliese.
Lo cierto es que resulta muy útil, dado que en ocasiones la comparación que necesitamos hacer es tan compleja que resulta más práctica hacerla “a mano” que utilizar estas funciones “assert”, por lo que en caso de no cumplirse podemos conseguir el mismo resultado invocando a esta función “fail()”.
Antes de continuar, me gustaría comentar que hay quien opina que un test debería tener una sola verificación (un solo assert o verify, por ejemplo) para ser realmente unitario, por lo que lo más correcto sería repetir el escenario tantas veces como sea necesario e identificar qué vamos a probar en cada uno de los tests.
Siendo así, el ejemplo de la tercera función deberíamos dividirlo en tres y diferenciar entre la prueba que verifica que el objeto es el mismo, la que verifica que el dato ID no se ha modificado y la que verifica que el dato Name no se ha modificado.
Nota: en Kotlin, si definimos User como una data class, las primera verificación implica las dos siguientes, dado que el hashCode se genera a partir de los valores de sus properties.
Si somos exigentes, incluso en el primer ejemplo no solo estamos verificando que se invoca al DAO con el ID correcto, sino que también verificamos que se invoca una única vez.
Es posible que esta regla sea correcta desde un punto de vista ortodoxo, pero desde un punto de vista práctico siempre hemos coincidido en los equipos en los que he colaborado en que resulta mucho más conveniente agrupar las validaciones siempre y cuando se trate de una prueba bien acotada, aunque como de costumbre, está abierto a opiniones e interpretaciones.
Como ya comentamos en la introducción, debemos saber medir y encontrar el equilibrio para no dedicar un tiempo excesivo al desarrollo de tests, pero sin renunciar a un nivel de detalle razonable.
Si aceptas un consejo, te recomiendo que empieces realizando las verificaciones en conjunto (cuidado, siempre que sea una prueba bien acotada) y, si tienes tiempo, las segmentes posteriormente en verificaciones más aisladas, de manera que desde un inicio cuentes con unos tests suficientemente descriptivos y, poco a poco, su nivel de detalle aumente aún más.
De nuevo, todo dependerá del contexto del proyecto y los tiempos que manejes.
Vamos a probar ahora otra serie de requisitos:
Por simplicidad, vamos a reducir los ejemplos a las funciones de test. Siempre y cuando no te indique lo contrario, la función setUp(), así como los fields definidos a nivel de clase, permanecerán inalterados respecto al ejemplo inicial.
@Test
fun ifDaoDoesNotReturnUserThenRepositoryAsksToApi(){
whenever(userDao.getUser(any())).thenReturn(null)
userRepository.getUser(0)
verify(userDao, times(1)).getUser(any())
verify(userApi, times(1)).getUser(any())
}
@Test
fun UserIsAskedToDaoBeforeAskingToApi(){
whenever(userDao.getUser(any())).thenReturn(null)
userRepository.getUser(0)
val orderVerifier: InOrder = inOrder(userDao, userApi)
orderVerifier.verify(userDao).getUser(any())
orderVerifier.verify(userApi).getUser(any())
}
@Test
fun IfUserIsRecoveredFromApiThenThatUserIsStoredThroughDao(){
whenever(userDao.getUser(any())).thenReturn(null)
userRepository.getUser(0)
val captor : KArgumentCaptor = argumentCaptor()
verify(userApi, times(1)).getUser(any())
verify(userDao, times(1)).storeUser(captor.capture())
assertEquals(captor.firstValue, userFromApi)
}
En la primera función, no estamos haciendo nada nuevo. No obstante, merece la pena destacar que el escenario de pruebas debe cambiar para este test respecto al definido por defecto en el setUp().
En concreto, en este caso, debemos devolver un nulo en el DAO para verificar que el API entra en acción tal como se ha definido en los requisitos.
Para ello, es suficiente con reemplazar el comportamiento del mock configurándolo de nuevo, dado que esta función de test es invocada justo después de la función setUp, como ya hemos visto.
Después, simplemente confirmamos que el método getUser() se ha invocado en ambas entidades.
La segunda función trae novedades. Ahora queremos verificar no solo que se han invocado ciertas funciones, sino que el orden es el correcto.
Para ello, basta con inicializar el objeto InOrder pasándole en el constructor los mocks involucrados en esta verificación. Posteriormente, la verificación es similar a la que ya hemos visto, solo que debemos invocarla a través de la función “verify” de este objeto InOrder.
Si lo encontramos útil, esta función “verify” también admite un VerificationMode, es decir, que tal como venimos haciendo, podemos delimitar el número de interacciones con el mismo conjunto de funciones times(), never(), atLeast(), etc.
El ArgumentCaptor (KArgumentoCaptor con Mockito-Kotlin) es una clase realmente útil a la hora de verificar los parámetros de entrada de una función.
En el ejemplo de esta tercera función de test estamos verificando que el usuario recuperado desde el API es almacenado a través del DAO.
Mediante la configuración del mock del API hemos podido definir qué objeto User concreto debía devolver siempre que su función getUser fuese invocada, como es el caso de esta prueba.
Sin embargo, como debemos plantear el test como si el Repository fuese una caja negra, no tenemos garantía de que el usuario almacenado sea realmente el devuelto por el API. Por ponernos en lo peor, el Repository podría haber creado un usuario nuevo y rellenado sus datos con valores hardcodeados.
Bien, pues esto esto es lo que ponemos a prueba con el ArgumentCaptor. Este nos permite capturar los argumentos intercambiados entre objetos. Su utilización es tan sencilla como inicializarlo definiendo el tipo de argumento que vamos a capturar y, posteriormente, capturandolo mediante un bloque verify.
Tras ello, tendremos el valor disponible tal como vemos en el ejemplo. Como una función puede tener un parámetro de entrada de tipo vararg (número de elementos indefinido), esta implementación de Mockito-Kotlin maneja de una manera declarativa los tres primeros valores (firstValue, secondValue, thirdValue), aunque podemos obtener la lista completa de argumentos que maneja internamente Mockito a partir de la variable “allValues”, que es una lista de elementos.
En la práctica, casi siempre utilizaremos el “firstValue” o simplemente “value”, si trabajas directamente con Mockito.
Volviendo al ejemplo, y sabiendo que hemos forzado al API a devolver el usuario “userFromApi”, lo que hacemos es garantizar que es esta misma instancia la que se almacena a través del DAO con su función storeUser.
Para ser más exhaustivos, podríamos verificar como ya hemos hecho previamente que el ID y el Name no han sido modificados, ¡pero eso os lo dejo ya a vosotros!
Probar que el comportamiento es el esperado cuando todo va bien es solo parte del trabajo. Lo cierto es que en ocasiones es incluso más importante probar que la entidad a prueba se comporta bien cuando algo falla.Vamos a ver cómo hacer estas pruebas. En primer lugar, vamos a modificar ligeramente la implementación de la función getUser en nuestra clase UserRepositoryImpl para poder ejemplificar estas pruebas, dado que hasta el momento no hemos tratado errores.
override fun getUser(id: Int): User{
userDao.getUser(id)?.let { user ->
return user
} ?: run{
val user = userApi.getUser(id)
try {
userDao.storeUser(user)
}catch (e: Exception){
throw IllegalArgumentException("Storing failed!", e)
}
return user
}
}
Los nuevos requisitos son los siguientes, aunque carezcan de sentido:
¡A por los tests!
@Test(expected = IllegalStateException::class)
fun whenDaoFailsRecoveringUserAnIllegalStateExceptionIsThrown(){
doAnswer { throw IllegalStateException() }.`when`(userDao).getUser(any())
userRepository.getUser(0)
}
@Test
fun whenDaoFailsRecoveringUserTheExceptionIsPropagatedAsIs(){
val exception = IllegalStateException()
doAnswer { throw exception }.`when`(userDao).getUser(any())
try {
userRepository.getUser(0)
fail()
}catch(e: Exception){
assertEquals(e, exception)
}
}
@Test(expected = IllegalStateException::class)
fun whenApiFailsRecoveringUserAnIllegalStateExceptionIsThrown(){
whenever(userDao.getUser(anyInt())).thenReturn(null)
doAnswer { throw IllegalStateException() }.`when`(userApi).getUser(any())
userRepository.getUser(0)
}
@Test
fun whenApiFailsRecoveringUserTheExceptionIsPropagatedAsIs(){
whenever(userDao.getUser(anyInt())).thenReturn(null)
val exception = IllegalStateException()
doAnswer { throw exception }.`when`(userApi).getUser(any())
try {
userRepository.getUser(0)
fail()
}catch(e: Exception){
assertEquals(e, exception)
}
}
@Test
fun whenDaoFailsStoringUserTheExceptionIsWrappedProperly(){
whenever(userDao.getUser(anyInt())).thenReturn(null)
val exception = IllegalStateException()
doAnswer { throw exception }.`when`(userDao).storeUser(any())
try {
userRepository.getUser(0)
fail()
}catch(e: Exception){
assert(e is IllegalArgumentException)
assertEquals(e.cause, exception)
assertEquals(e.message, "Storing failed!")
}
}
En la primera función añadimos un comportamiento adicional al mock. Tal como hemos descrito en los nuevos requisitos, el DAO al fallar lanzará una IllegalStateExceptión, y así lo configuramos para esta prueba.
El objetivo es exclusivamente verificar que el tipo de error es el esperado. Para ello, la anotación @Test cuenta con la capacidad de definir el tipo de Throwable que se espera ser lanzado como resultado de la ejecución del test, por lo que resulta muy sencillo de validar.
En la segunda función, lo que queremos validar es que la excepción se propaga sin modificar, tal como se ha lanzado desde el DAO (misma instancia de la excepción).
Para ello, es útil el bloque que ya comentamos anteriormente en el post y que es necesario por una incompatibilidad de Kotlin a día de escritura de este post para lanzar excepciones de la manera simplificada (con la función doThrow).
Como esperamos que se lance una excepción, la función fail() que también viene muy a mano, permitiéndonos forzar el fallo del test si alcanzamos esa línea (es decir, no se ha lanzado ningún excepción).
La tercera y cuarta funciones son equivalentes, pero para el API. En este caso, como el API es únicamente invocado si el DAO no devuelve datos, debemos añadir también esa configuración al mock.
Por último, en la última función, realizamos alguna comprobación adicional, aunque el bloque es parecido a los anteriores.
En concreto, verificamos que la excepción devuelta no es del tipo original, sino de tipo IllegalArgumentException, tal como definimos en los requisitos. Además, comprobamos que esta excepción realmente envuelve a la original (cause) y que contiene el mensaje correcto.
Llegados a este punto, lo cierto es que has adquirido el conocimiento suficiente como para poner a prueba, probablemente, el 90% de la lógica de negocio de tu aplicación a través de tests unitarios, desde la capa de presentación (sin incluir las vistas) hasta el acceso a datos.
Habrá tests más sencillos y otros más complejos, pero si la arquitectura está orientada a la testabilidad de la app, como ya vimos en el post de introducción, te aseguro que podrás afrontar la gran mayoría de tests con este conjunto limitado de funciones.
Por supuesto, llegará el momento en el que encuentres algunas limitaciones o complicaciones que requerirán de alguna función adicional o de alguna configuración de mock más compleja, pero hay muchísima documentación al respecto en la red, así que no temas.
Al inicio del artículo os dije que os dejaría un ejemplo sobre cómo crear un mock cuando la respuesta de una función está envuelta en un objeto Call, tal como exige Retrofit. Recordemos el ejemplo:
Interfaz:
interface UserApi {
@GET("users/{user_id}")
fun getUser(@Path("user_id") id: String): Call
}
Cuando trabajamos con un objeto Call y recuperamos el dato a través de, por ejemplo, su método síncrono execute(), debemos tener en cuenta que a su vez este nos devuelve un objeto Response, cuyo método body() devuelve el objeto que realmente hemos obtenido en la llamada, en este caso un User.
Para poder crear crear un mock que devuelva el User que nosotros queramos (o bien devuelva un error) podemos hacerlo de la siguiente manera:
val getUserCall: Call = mock()
val user = User(1234, "name") //whatever we want to return
val getUserResponse201: Response = Response.success(
user,
okhttp3.Response.Builder()
.code(201)
.message("OK")
.protocol(Protocol.HTTP_1_1)
.request(Request.Builder().url("http://34.140.28.124:1337/").build())
.build()
)
var getUserResponse200: Response = Response.success(user) //Its simpler for a 200
var getUserResponse404: Response = Response.error(404, mock()/*Response body also mocked*/) //Also for an error
whenever(getUserCall.execute()).thenReturn(getUserResponse201)
whenever(userApi.getUser(any())).thenReturn(getUserCall)
En el ejemplo vemos que para forzar respuestas 200 o cualquier error, no requerimos del Response Builder y la declaración se simplifica bastante.
Lo prometido es deuda, así que antes de dar por cerrado este post, veamos cómo definir un test de integración es muy sencillo. Veréis que desde el punto de vista de Mockito y sus funciones, no hay ninguna diferencia. La única diferencia la estableceremos a través de las inicializaciones.
Supongamos que queremos probar un test de integración que comprueba que un flujo es correcto desde que el Presenter recibe el evento de click (que representa el trigger para obtener el usuario), hasta que el DAO es invocado, y vuelta, hasta que la vista recibe el elemento que debe mostrar. En este caso, queremos realizar una serie de pruebas basadas en los distintos tipos de datos que puede devolver el DAO o el API, como venimos haciendo a nivel de Repository.
Bien, pues en este caso, vamos a involucrar a las siguientes entidades (reales, sin mocks):
También, para tener el control de los datos recuperados de Usuario, vamos a volver a inyectar los mocks de las siguientes entidades:
Además, como novedad, inyectamos al Presenter el mock de la Interfaz de la vista, dado que para estos ejemplos estamos trabajando con una arquitectura MVP.
Esta es la estructura de clases que tenemos:
View (interfaz):
interface UserDetailView {
fun doSomethingWithUser(user: User)
}
Nota: la implementación de esta vista podría ser, por ejemplo, un Fragment, pero nos es indiferente para este ejemplo.
Presenter (interfaz):
interface UserDetailPresenter {
fun onClick()
}
Presenter (implementación):
class UserDetailPresenterImpl(private val userDetailView: UserDetailView, private val getUserUseCase: GetUserUseCase) : UserDetailPresenter {
override fun onClick() {
val user = getUserUseCase.getUser(0)
userDetailView.doSomethingWithUser(user)
}
}
UseCase (interfaz):
interface GetUserUseCase {
fun getUser(id: Int): User
}
UseCase (implementación):
class GetUserUseCaseImpl(private val repository: UserRepository) : GetUserUseCase {
override fun getUser(id: Int): User = repository.getUser(id)
}
El Repository, DAO y API ya los conocemos bien.
A la hora de nombrar la clase de Test, ahora es mucho más abierto, pues dependerá de aquello que vamos a probar. Como recomendación, os sugiero que mantengáis al menos un sufijo común para este tipo de tests del tipo “IntegrationTest”. Para el ejemplo, la nombraremos “RecoveringUserIntegrationTest”.
class RecoveringUserIntegrationTest {
//Test subject
lateinit var userDetailPresenter: UserDetailPresenter
//Collaborators (no mocks)
lateinit var getUserUseCase: GetUserUseCase
lateinit var userRepository: UserRepository
//Collaborators (mocks)
lateinit var userDetailView: UserDetailView
lateinit var userApi: UserApi
lateinit var userDao: UserDao
@Before
fun setUp() {
//Init Mocks
userDetailView = mock()
userApi = mock()
userDao = mock()
//Collaborators initialization (no mocks)
userRepository = UserRepositoryImpl(userDao, userApi)
getUserUseCase = GetUserUseCaseImpl(userRepository)
//Test subject initialization
userDetailPresenter = UserDetailPresenterImpl(userDetailView, getUserUseCase)
}
@Test
fun whenDaoReturnsUserItIsPropagatedToTheViewAsIs(){
val testUser = User(2, "integrationTest")
whenever(userDao.getUser(any())).thenReturn(testUser)
userDetailPresenter.onClick()
val captor : KArgumentCaptor = argumentCaptor()
verify(userDetailView, times(1)).doSomethingWithUser(captor.capture())
assertEquals(captor.firstValue, testUser)
//Validate ID/Name if wanted
}
}
Como seguro que a estas alturas ya entiendes perfectamente, lo que hemos hecho es configurar el mock del DAO para devolver un usuario específico y comprobar que el mock de la vista recibe ese mismo usuario.
Recordemos que debemos mantener un enfoque de caja negra, en la que solo queremos manipular los extremos, que en este caso son por un lado la View y por el otro el DAO y el API. Es por ello que estas son las únicas clases mockeadas.
Por el contrario, todo el resto de clases involucradas utilizan la misma implementación que usarán en la aplicación en un escenario real. Aunque hayamos hecho referencia al Presenter como el sujeto de pruebas (por ser el cual a través interactuamos), tal como hemos visto todas las clases involucradas son realmente sujetos de pruebas de este test.
Si este test falla, sabremos qué flujo concreto ha fallado (la propagación del User desde el DAO hasta la View), pero no en qué punto exacto dentro de este flujo.
Podríamos decir que ya eres un iniciado en esto de los tests unitarios y sus primos, los tests de integración.
Para los tests de UI, sin embargo, el paradigma cambia un poco. Todo lo aprendido hasta este punto sigue siendo de gran utilidad, pero deberemos echar mano a otras funciones de un framework de testing diferente, Espresso.
Todo esto lo veremos en detalle en el siguiente post (con un bonus relacionado con Kotlin y sus funciones infix). Si te interesa, ¡allí te espero!
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.
Cuéntanos qué te parece.