DEV Community

Cover image for Testing en Android
disced
disced

Posted on

Testing en Android


Tipos de Tests

Los tres tipos de tests que se realizan en Android (en general en cualquier lenguaje/framework son iguales) son los siguientes:

  • Unit Tests: tests por unidad, es decir testear las clases o funciones de forma individual y verificar que cada una haga lo que se supone que debe hacer.
  • Integration Tests: tests que validan la integración entre diferentes módulos
  • UI Tests: tests de la interfaz del usuario, o end to end

Pirámide testing

Google recomienda dividir los tipos de tests de la siguiente forma:

Tipo Test Porcentaje
Unit tests 70%
Integration Test 20%
UI Tests 10%

Directorios del proyecto

Existen dos formas de ejecutar los tests en Android:

  1. En la maquina local, mediante la JVM.
  2. En un dispositivo real (o emulado).

Dependiendo de donde se quieran ejecutar las pruebas (en local o real) existen dos directorios en el proyecto:

  1. com/example/project/tests/: local
  2. com/example/project/androidTests/: real

Directorio tests

En dicho directorio se guardaran todos los test relacionados con la ejecución en la maquina local.

Por lo general serán los tests unitarios los que se guarden en dicho directorio porque no acceden a recursos del dispositivo y tampoco tienen que interactuar con el dispositivo.

Directorio androidTests

En este directorio se guardaran todos los tests que se vayan a ejecutar en el dispositivo real o en un emulador.

Los tipos de tests que se guardarán en dicho directorios son tests de integración y e2e (end-to-end).

Ejemplos

En los ejemplos siguientes, no muestro como añadir las dependencias necesarias para los tests y tampoco menciono que dependencias son necesarias. Consultar la guía oficial en las referencias para ver la implementación completa.

Test unitarios

Estos tipos de tests a mi parecer son los mas sencillos de entender, ya que pruebas cosas de forma individual, funciones o métodos de clases.

Imagina que vamos a querer comprobar dos métodos de la clase MyFunctions:

  • El primer método se encarga de sumar dos números.
  • El segundo devuelve la fecha de hoy en formato dd-MM-yyyy

Son dos métodos muy básicos pero útiles para entender las pruebas unitarias

Clase a testear

class MyFunctions {
    fun add(a: Int, b: Int): Int {
        return a + b
    }

   fun getCustomDate(): String {
        val date = Date()
        val format = SimpleDateFormat("dd-MM-yyyy", Locale.getDefault())
        return format.format(date)
    }
}
Enter fullscreen mode Exit fullscreen mode

Para testear dichas funciones vamos a crear el fichero com.example.project/tests/MyFuncionsTests.kt y dentro estará la lógica que comprobará que cada función hace lo que debe hacer.

Una buena práctica para realizar tests es provocar que la aplicación falle de cualquier manera. Es decir utilizarla de la forma en la que no se había pensado.

Por ejemplo, si un input está pensado para introducir números, vamos a introducir texto a ver que ocurre. Debemos pensar como un usuario y no como el desarrollador que ha implementado dicha funcionalidad.

Para realizar los tests utilizamos JUnit que nos provee de las anotaciones @Before, @After y @Test entre otras.

class MyFunctionsTest {
    private lateinit var myFunctions: MyFunctions

    // Se ejecuta antes de ejecutar cada test
    @Before
    fun setUp() {
        myFunctions = MyFunctions()
    }

    /*             Tests            */

    @Test
    fun `add function correct addition`() {
        val resultado = myFunctions.add(3, 2)
        assertEquals(5, resultado)
    }

    @Test
    fun `add function incorrect addition`() {
        val resultado = myFunctions.add(3, 2)
        assertNotEquals(6, resultado)
    }

    @Test
    fun `get custom date format dd-MM-yyyy`() {
        val fechaEsperada = SimpleDateFormat("dd-MM-yyyy").format(Date())
        val fechaObtenida = myFunctions.getCustomDate()
        assertEquals(fechaEsperada, fechaObtenida)
    }
}
Enter fullscreen mode Exit fullscreen mode

Las anotaciones se utilizan para indicar que es cada método de la clase, ya que si no se indica mediante las anotaciones de JUnit, son simples métodos dentro de una clase.

Anotaciones comunes de JUnit:

Anotacion Descripcion
@Test Indica que el método es un Test
@Before El método se ejecutará antes de cada test. Si hay 3 tests se ejecuta 3 veces
@After Igual que @Before pero después de cada test
@BeforeClass Se ejecutará dicho método una vez antes de ejecutar los tests
@AfterClass Se ejecutará el método al finalizar la ejecución de todos los tests

Existen mas anotaciones pero las básicas son las de la tabla.

Un punto importante es que las anotaciones BeforeClass y AfterClass deben anotar a métodos estáticos y públicos; en java con poner el método como public static funciona, en kotlin el método debe estar dentro de un companion object (para que sea static) y utilizar la anotación @JvmStatic aparte de @BeforeClass o @AfterClass.

Se puede observar que los nombres de las funciones en los tests pueden ir nombradas entre comillas inversas y con espacios en blanco, para que quede mas entendible.

  1. El primer test utiliza la función add con dos parámetros a sumar y verifica que el resultado de la suma es el esperado.
  2. El segundo, verifica la misma función pero que el resultado a comprobar no coincida con el esperado.
  3. El ultimo verifica que la fecha devuelta por la función getCustomDate es igual que nuestro resultado esperado.

Test de integración

Los tests de integración en la teoría parecen sencillos de entender, pero a mi, en la practica me han resultado mas complejos, ya que no me quedaba claro cuando se pasaba de pruebas unitarias a pruebas de integración.

Por ejemplo, si vamos a verificar que un ViewModel interactúa adecuadamente con una base de datos (como Room) o con una API, estaríamos realizando un test de integración.

Del mismo modo, si el ViewModel interactúa con un repositorio (patron repository), y este repositorio a su vez se comunica con un DataStore, este escenario también seria un test de integración.

Pongamos el caso de testear la integración entre ViewModel <--> API, en esta prueba, lo que se pretende verificar es el funcionamiento entre los dos componentes. Es decir que el test seguirá siendo de integración si accedemos a los datos reales de la API o utilizamos un Mock.

  1. Si accedemos a los datos reales de la API estamos testeando la integración real.
  2. Si utilizamos un Mock para la API, estamos testeando como el ViewModel maneja los datos recibidos desde la API.

Otro ejemplo podría ser probar la clase que interactúa con la API. Si dicha clase utiliza el patrón Repository lo que vamos a testear es el repositorio de la API, entonces seria un test de integración ya que se verifica nuestro código con un componente externo. En cambio, si vamos a querer testear la lógica interna del repositorio, podríamos mockear los datos y esto pasaría a ser un test unitario.

En este ejemplo tenemos lo siguiente:

  • Acceso a base de datos (Room).

    • El objeto que configura la base de datos.
    • El obtejo entity (referente a la tabla)
    • El DAO (Data Access Objetc) las consultas sql
    • La implementación al acceso a datos mediante el patrón Repository
      • Un interface (repository)
      • La implementación del interface
  • El ViewModel que utiliza el Repository para acceder a los datos.

Diagrama de test de integracion

Y lo que vamos a testear es la integración entre el ViewModel y la Base de Datos.

ViewModel

@HiltViewModel
class UserViewModel @Inject  
constructor(/*Inyeccion Dependencias*/var userRepo: UserRepository) : ViewModel() {  

    private val _user = MutableStateFlow<UserEntity?>(null)  
    val user: StateFlow<UserEntity?> = _user  

    fun getUser(id: Int) {  

        // Ejecutamos una corutina en el scope del ViewModel
        viewModelScope.launch {  

            // Utilizamos el método getUser del Repository
            userRepo.getUser(id).collect { user ->  
                _user.value = user  

            }  
        }  
    }  

    fun saveUser(newUser: UserEntity) {  
        viewModelScope.launch {  
            userRepo.saveUser(newUser)  
        }  

    }  
}
Enter fullscreen mode Exit fullscreen mode

Acceso a datos

DB

La definición de la base de datos, especificando la tabla (entity) y las consultas (el DAO como abstract fun)

@Database(entities = [UserEntity::class], version = 1)  
abstract class UsersDb : RoomDatabase() {  

    abstract fun usersDao(): UserDao  

    companion object {  

        private var instance: UsersDb? = null  
        fun getInstance(ctx: Context): UsersDb {  
            if (instance == null) {  
                instance =  
                    Room.databaseBuilder(ctx.applicationContext, UsersDb::class.java, "users_db")  
                        .build()  
            }  

            return instance!!  
        }  
    }  
}
Enter fullscreen mode Exit fullscreen mode
Entity (tabla)

La tabla con su schema

@Entity(tableName = "users")  
data class UserEntity(  
    @PrimaryKey val id: Int,  
    @ColumnInfo(name = "name") val name: String,  
    @ColumnInfo(name = "mail") val mail: String  
)
Enter fullscreen mode Exit fullscreen mode
DAO

Las consultas a la base de datos

@Dao  
interface UserDao {  

    @Query("SELECT * FROM users WHERE id = :id")  
    fun getUser(id: Int): Flow<UserEntity>  

    @Insert(onConflict = OnConflictStrategy.REPLACE)  
    suspend fun saveUser(user: UserEntity)  
}
Enter fullscreen mode Exit fullscreen mode

Implementación acceso a datos

Repository
interface UserRepository {

    // Declaración de los métodos
    fun getUser(id: Int): Flow<UserEntity>  
    suspend fun saveUser(user: UserEntity)  
}
Enter fullscreen mode Exit fullscreen mode
Repository Implementation
class UserImpl(private val userDao: UserDao) : UserRepository {  

    // Definición de los métodos
    override fun getUser(id: Int): Flow<UserEntity> {  
        return userDao.getUser(id)  
    }  

    override suspend fun saveUser(user: UserEntity) {  
        return userDao.saveUser(user)  
    }  
}
Enter fullscreen mode Exit fullscreen mode

Por no añadir más código y hacer la explicación las complicada he optado por omitir la Inyección de Dependencias. Saber que la realizo con Dagger Hilt y proveo las clases UsersDb, UserDao y UserImpl.

Tests

class UserViewModelTest {  
    private lateinit var database: UsersDb  
    private lateinit var user: UserEntity  
    private lateinit var userDao: UserDao  
    private lateinit var userRepository: UserRepository  

    private lateinit var viewModel: UserViewModel  

    @Before  
    fun setUp() {  
        // Contexto de la app en modo Test  
        val context = InstrumentationRegistry.getInstrumentation().context  

        // Creamos la BBDD ficticia en RAM  
        database = Room.inMemoryDatabaseBuilder(context, UsersDb::class.java).build()

        userDao = database.usersDao()  
        userRepository = UserImpl(userDao)  
        viewModel = UserViewModel(userRepository)  

        user = UserEntity(1, "Alice", "alice@mail.com")  
    }  

    @After 
    fun tearDown() {
        // Este metodo se ejcutará después de cada test
        database.close()  
    }  

    @Test  
    fun shouldViewModelSaveUserInDB() = runBlocking {  
        // Al ser un metodo 'suspend' debemos utilizar 'runBlocking' para ejecutar la corutina y  
        // bloquear el hilo.  

        // El ViewModel guarda el usuario        
        viewModel.saveUser(user)  

        // Obtenemos usuario con id: 1
        val savedUser = userRepository.getUser(1).first()  

        // Verificamos que el usuario es el mismo que el de la base de datos.  
        // Si es el mismo, el viewmodel ha funcionado correctamente a la hora        
        // de guardar el usuario en bbdd.        
        assertEquals(user, savedUser)  
    }  

    @Test  
    fun saveUser() {  
        runBlocking {
            // Probamos a guardar un usuario desde el ViewModel
            viewModel.saveUser(user)  

            // Obtenemos un usuario con id: 1
            val userFromDb = userDao.getUser(1).first()  

            // Verificamos que los usuarios son iguales
            assertEquals(userFromDb, user)  
        }  
    }  
}
Enter fullscreen mode Exit fullscreen mode

Test de IU

En este ejemplo testearé un botón en Jetpack Compose, para realizarlo lo haremos como en el ejemplo.

Composable

@Composable  
fun Greeting(name: String) {  
    var showDialog by remember { mutableStateOf(false) }  
    Button(onClick = { showDialog = !showDialog }  
    ) {
        Text(text = "Click me!")  
    }  

    if (showDialog) {  
        Log.d("Saludo","showDialog")  
        onClickDialog()  
    }}  

@Composable  
fun onClickDialog() {  
    Dialog(onDismissRequest = { /*TODO*/ }) {  
        Text(text = "Dialogo")  
    }
Enter fullscreen mode Exit fullscreen mode

Test

class GreetingComposableTesting {  

    private val name = "TEST"  

    // Creamos una regla de JUnit para un Composable
    @get:Rule
    val rule = createComposeRule()  

    @Before
    fun setContent() {  
        // A la regla le añadimos el composable a testear
        rule.setContent { Greeting(name) }  
    }  

    @Test  
    fun buttonExists() {  
    // Comprobamos que el nodo con el texto "Click me!" existe.  
        rule  
            .onNodeWithText("Click me!")  
            .assertExists("No existe")  
    }  

    @Test
    fun clickButton() {  
        rule  
            .onNodeWithText("Click me!")  
            // Verificamos que el nodo es clickable
            .assertHasClickAction()  
            // Hacemos click en el botón
            .performClick()  
    }  

}
Enter fullscreen mode Exit fullscreen mode

Adjunto cheatsheet de testing en Jetpack Compose:

Jetpack Compose testing cheatsheet

Referencias

Top comments (0)