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
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:
- En la maquina local, mediante la JVM.
- En un dispositivo real (o emulado).
Dependiendo de donde se quieran ejecutar las pruebas (en local o real) existen dos directorios en el proyecto:
-
com/example/project/tests/
: local -
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)
}
}
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)
}
}
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.
- 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. - El segundo, verifica la misma función pero que el resultado a comprobar no coincida con el esperado.
- 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.
- Si accedemos a los datos reales de la API estamos testeando la integración real.
- 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.
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)
}
}
}
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!!
}
}
}
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
)
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)
}
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)
}
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)
}
}
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
yUserImpl
.
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)
}
}
}
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")
}
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()
}
}
Adjunto cheatsheet de testing en Jetpack Compose:
Top comments (0)