Testing E2E: ToDo App
Clase 14 de 16 • Curso de Android Testing
Resumen
La implementación de pruebas end-to-end en aplicaciones Android es fundamental para garantizar que todos los componentes funcionen correctamente en conjunto, simulando el comportamiento real de los usuarios. Estas pruebas nos permiten verificar flujos completos de navegación, interacción con la interfaz y persistencia de datos, proporcionando mayor confianza en la calidad de nuestro software antes de llevarlo a producción.
¿Cómo preparar una aplicación Android para pruebas end-to-end?
Para realizar pruebas end-to-end efectivas en una aplicación Android, necesitamos adaptar nuestra aplicación para que sea "testeable". Esto implica configurar correctamente el entorno de pruebas y hacer que los componentes de la aplicación sean accesibles para los tests. En el caso de nuestra aplicación de tareas (To-Do App), realizaremos dos adaptaciones fundamentales:
- Reemplazar la base de datos real por una en memoria: Esto nos permitirá tener un entorno de pruebas aislado y confiable.
- Configurar la navegación de la app: Para poder simular flujos reales en los tests, desde la lista de tareas hasta los formularios de creación o edición.
Configuración de dependencias para testing
El primer paso es agregar las librerías necesarias para realizar pruebas de instrumentación. Debemos modificar nuestros archivos de configuración Gradle:
- Agregar las versiones de las librerías en el archivo
libs.versions.gradle
- Incluir las dependencias de testing en el archivo
build.gradle
a nivel de aplicación:
dependencies {
// Otras dependencias...
// Testing
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.espresso.core)
androidTestImplementation(libs.hilt.android.testing)
// Más dependencias de testing...
}
Es importante destacar la inclusión de hilt.android.testing
, que nos permitirá crear tests que involucren inyección de dependencias con Hilt.
Creación de un Custom Test Runner
Por defecto, Android utiliza AndroidJUnitRunner
para las pruebas de instrumentación. Sin embargo, cuando trabajamos con inyección de dependencias mediante Hilt, necesitamos crear un Custom Test Runner:
class CustomTestRunner : AndroidJUnitRunner() {
override fun newApplication(
cl: ClassLoader?,
className: String?,
context: Context?
): Application {
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
}
Este Custom Test Runner nos permitirá utilizar HiltTestApplication
como base para nuestras pruebas, facilitando la inyección de dependencias durante los tests.
Después, debemos actualizar nuestro archivo build.gradle
para utilizar este Custom Test Runner:
android {
// Otras configuraciones...
defaultConfig {
// Otras configuraciones...
testInstrumentationRunner = "com.onedgames.todoapp.CustomTestRunner"
}
}
¿Por qué necesitamos una base de datos en memoria para testing?
Una de las claves para realizar pruebas efectivas es el aislamiento. Cuando trabajamos con bases de datos reales en pruebas, podemos encontrarnos con varios problemas:
- Persistencia no deseada: Los datos insertados en un test pueden afectar a otros tests.
- Inconsistencia: Si insertamos una tarea con ID 1 en un test y luego intentamos hacer lo mismo en otro test, el segundo podría fallar.
- Falta de predictibilidad: Las pruebas deben ser deterministas y reproducibles.
Implementación de un módulo de base de datos para testing
Para solucionar estos problemas, crearemos un módulo específico para testing que reemplazará al módulo de datos productivo:
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [DataModule::class]
)
object TestDataModule {
@Provides
@Singleton
fun provideTaskDatabase(@ApplicationContext context: Context): TaskDatabase {
return Room.inMemoryDatabaseBuilder(
context,
TaskDatabase::class.java
).allowMainThreadQueries().build()
}
// Otros providers...
}
Las características clave de este módulo son:
- @TestInstallIn: Indica que este módulo se instalará solo durante las pruebas.
- replaces = [DataModule::class]: Especifica que este módulo reemplazará al módulo productivo.
- inMemoryDatabaseBuilder: Crea una base de datos que existe solo en memoria y se destruye cuando finaliza el test.
- allowMainThreadQueries(): Permite realizar consultas en el hilo principal, lo que simplifica la ejecución de tests.
¿Cómo preparar los componentes de UI para testing?
Para que nuestras pruebas puedan interactuar correctamente con la interfaz de usuario, es fundamental agregar descripciones de contenido (content descriptions) a los elementos de la UI. Estas descripciones nos permitirán identificar y manipular los componentes durante las pruebas.
Configuración de HomeScreen
En la pantalla principal de nuestra aplicación, necesitamos agregar descripciones a varios elementos:
// Para la pantalla completa
Scaffold(
modifier = Modifier
.fillMaxSize()
.semantics { contentDescription = "HomeScreen" },
// Resto del código...
)
// Para el estado vacío
Box(
modifier = Modifier
.semantics { contentDescription = "EmptyTaskState" }
// Resto del modificador...
)
// Para cada tarea pendiente
Row(
modifier = Modifier
.semantics { contentDescription = "PendingTask-$title" }
// Resto del modificador...
)
// Para el botón de agregar tarea
FloatingActionButton(
modifier = Modifier
.semantics { contentDescription = "AddNewTaskButton" },
// Resto del código...
)
Configuración de TaskScreen
De manera similar, en la pantalla de detalle de tarea, agregamos descripciones a los elementos clave:
// Para la pantalla completa
Column(
modifier = Modifier
.fillMaxSize()
.semantics { contentDescription = "TaskScreen" },
// Resto del código...
)
// Para el botón de retroceso
IconButton(
modifier = Modifier
.semantics { contentDescription = "BackButton" },
// Resto del código...
)
// Para los campos de texto
OutlinedTextField(
modifier = Modifier
.semantics { contentDescription = "TaskTitleInput" },
// Resto del código...
)
OutlinedTextField(
modifier = Modifier
.semantics { contentDescription = "TaskDescriptionInput" },
// Resto del código...
)
// Para el botón de guardar
Button(
modifier = Modifier
.semantics { contentDescription = "SaveTaskButton" },
// Resto del código...
)
Agregar estas descripciones no solo mejora la testeabilidad de nuestra aplicación, sino que también contribuye a la accesibilidad, permitiendo que las herramientas de asistencia identifiquen correctamente los elementos de la interfaz.
Beneficios de una buena preparación para testing
Preparar adecuadamente nuestra aplicación para testing nos proporciona varios beneficios:
- Tests más robustos y confiables: Al aislar la base de datos y tener componentes bien identificados.
- Mayor facilidad para escribir y mantener tests: Las descripciones de contenido hacen que sea más sencillo interactuar con la UI.
- Mejor accesibilidad: Las descripciones de contenido también mejoran la experiencia para usuarios con discapacidades.
- Detección temprana de problemas: Las pruebas end-to-end pueden identificar problemas que las pruebas unitarias o de integración podrían pasar por alto.
La preparación adecuada de nuestra aplicación para testing es un paso fundamental para garantizar la calidad del software que desarrollamos. ¿Has implementado pruebas end-to-end en tus proyectos? Comparte tu experiencia y los desafíos que has enfrentado en la sección de comentarios.