Introducción a las Pruebas en Jetpack Compose
Jetpack Compose revolucionó el desarrollo de interfaces en Android con su enfoque declarativo, y las pruebas de UIs en Compose requieren una mentalidad fundamentalmente diferente en comparación con las pruebas tradicionales basadas en Views. A diferencia de las pruebas con Espresso (como se discute en Espresso & XCUITest: Mastering Native Mobile Testing Frameworks) que dependen de jerarquías de Views, las pruebas en Compose aprovechan el árbol de semántica—una estructura paralela que describe elementos de UI para propósitos de accesibilidad y testing.
El framework de testing de Compose proporciona una API poderosa e intuitiva que se integra perfectamente con JUnit y permite escribir pruebas que son tanto legibles como mantenibles. Ya sea que estés probando composables simples o flujos de navegación complejos, comprender los fundamentos del testing en Compose es esencial para garantizar la fiabilidad de la UI.
Configuración del Entorno de Pruebas
Configuración de Dependencias
Agrega las dependencias necesarias a tu build.gradle.kts
:
android {
defaultConfig {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
(como se discute en [Mobile Testing in 2025: iOS, Android and Beyond](/blog/mobile-testing-2025-ios-android-beyond)) (como se discute en [Appium 2.0: New Architecture and Cloud Integration for Modern Mobile Testing](/blog/appium-2-architecture-cloud)) }
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
// Compose BOM para alineación de versiones
val composeBom = platform("androidx.compose:compose-bom:2024.02.00")
implementation(composeBom)
androidTestImplementation(composeBom)
// Testing de Compose UI
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-test-manifest")
// Infraestructura de testing
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
}
Visión General de la Estructura de Pruebas
Componente | Propósito | Ubicación |
---|---|---|
ui-test-junit4 | APIs principales y ComposeTestRule | androidTest |
ui-test-manifest | Requerido para builds de debug | debug |
JUnit 4 | Test runner y aserciones | androidTest |
Espresso | Opcional, para testing de interoperabilidad con Views | androidTest |
Entendiendo el Árbol de Semántica
El árbol de semántica es la base del testing en Compose. Cada composable puede proporcionar información semántica a través de modificadores:
@Composable
fun LoginButton(onClick: () -> Unit) {
Button(
onClick = onClick,
modifier = Modifier
.testTag("login_button")
.semantics {
contentDescription = "Iniciar sesión en tu cuenta"
role = Role.Button
}
) {
Text("Iniciar Sesión")
}
}
Propiedades Semánticas Clave
- testTag: Identificador principal para encontrar nodos
- contentDescription: Descripción de accesibilidad
- text: Contenido de texto del composable
- role: Rol semántico (Button, Checkbox, etc.)
- stateDescription: Descripción del estado actual
Encontrando Nodos con Matchers
// Por test tag
composeTestRule.onNodeWithTag("login_button")
// Por texto
composeTestRule.onNodeWithText("Iniciar Sesión")
// Por content description
composeTestRule.onNodeWithContentDescription("Iniciar sesión en tu cuenta")
// Múltiples nodos
composeTestRule.onAllNodesWithTag("list_item")
// Matchers complejos
composeTestRule.onNode(
hasText("Iniciar Sesión") and hasClickAction() and isEnabled()
)
ComposeTestRule y Ciclo de Vida de las Pruebas
Creando Test Rules
class LoginScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun loginButton_whenClicked_triggersCallback() {
var loginClicked = false
composeTestRule.setContent {
LoginButton(onClick = { loginClicked = true })
}
composeTestRule.onNodeWithTag("login_button")
.performClick()
assert(loginClicked)
}
}
Variantes de Test Rules
Tipo de Rule | Caso de Uso | Método de Creación |
---|---|---|
createComposeRule() | Probar composables individuales | Configuración manual de contenido |
createAndroidComposeRule<Activity>() | Pruebas con contexto de Activity | Lanzamiento automático de Activity |
createEmptyComposeRule() | Escenarios avanzados con configuración personalizada | Control manual de Activity |
Controlando el Timing de las Pruebas
@Test
fun animation_completesSuccessfully() {
composeTestRule.setContent {
AnimatedComponent()
}
// Esperar a idle (animaciones completadas)
composeTestRule.waitForIdle()
// Esperar condición específica
composeTestRule.waitUntil(timeoutMillis = 3000) {
composeTestRule.onAllNodesWithTag("animated_item")
.fetchSemanticsNodes().size == 5
}
}
Probando Componentes UI con APIs de Compose
Aserciones Básicas
@Test
fun userProfile_displaysCorrectInformation() {
val user = User("Juan Pérez", "juan@example.com")
composeTestRule.setContent {
UserProfileCard(user = user)
}
composeTestRule.onNodeWithTag("user_name")
.assertExists()
.assertIsDisplayed()
.assertTextEquals("Juan Pérez")
composeTestRule.onNodeWithTag("user_email")
.assertTextContains("juan@example.com")
}
Probando Visibilidad y Estado Habilitado
@Test
fun submitButton_isDisabledWhenFormIncomplete() {
composeTestRule.setContent {
RegistrationForm(
email = "",
password = ""
)
}
composeTestRule.onNodeWithTag("submit_button")
.assertIsNotEnabled()
// Llenar campos del formulario
composeTestRule.onNodeWithTag("email_field")
.performTextInput("usuario@example.com")
composeTestRule.onNodeWithTag("password_field")
.performTextInput("ContraseñaSegura123")
composeTestRule.onNodeWithTag("submit_button")
.assertIsEnabled()
}
Probando Listas y Colecciones
@Test
fun productList_displaysAllItems() {
val products = listOf(
Product("Laptop", "$999"),
Product("Mouse", "$29"),
Product("Teclado", "$79")
)
composeTestRule.setContent {
ProductList(products = products)
}
composeTestRule.onAllNodesWithTag("product_item")
.assertCountEquals(3)
composeTestRule.onAllNodesWithTag("product_item")[0]
.assertTextContains("Laptop")
.assertTextContains("$999")
}
Probando Cambios de Estado y Recomposición
Pruebas de UI Basadas en Estado
@Test
fun counter_incrementsCorrectly() {
composeTestRule.setContent {
CounterScreen()
}
// Estado inicial
composeTestRule.onNodeWithTag("counter_text")
.assertTextEquals("Contador: 0")
// Disparar cambio de estado
composeTestRule.onNodeWithTag("increment_button")
.performClick()
// Verificar recomposición
composeTestRule.onNodeWithTag("counter_text")
.assertTextEquals("Contador: 1")
}
Probando Integración con ViewModel
@Test
fun loadingState_showsProgressIndicator() {
val viewModel = ProfileViewModel().apply {
_uiState.value = UiState.Loading
}
composeTestRule.setContent {
ProfileScreen(viewModel = viewModel)
}
composeTestRule.onNodeWithTag("loading_indicator")
.assertIsDisplayed()
// Simular datos cargados
viewModel._uiState.value = UiState.Success(userData)
composeTestRule.onNodeWithTag("loading_indicator")
.assertDoesNotExist()
composeTestRule.onNodeWithTag("user_content")
.assertIsDisplayed()
}
Probando Interacciones del Usuario
Interacciones de Click y Tap
@Test
fun favoriteButton_togglesState() {
var isFavorite by mutableStateOf(false)
composeTestRule.setContent {
FavoriteButton(
isFavorite = isFavorite,
onToggle = { isFavorite = !isFavorite }
)
}
composeTestRule.onNodeWithTag("favorite_button")
.assertHasContentDescription("Agregar a favoritos")
.performClick()
composeTestRule.onNodeWithTag("favorite_button")
.assertHasContentDescription("Quitar de favoritos")
}
Probando Entrada de Texto
@Test
fun searchField_filtersResults() {
composeTestRule.setContent {
SearchableList(items = testItems)
}
composeTestRule.onNodeWithTag("search_field")
.performTextInput("Android")
composeTestRule.waitForIdle()
composeTestRule.onAllNodesWithTag("search_result")
.assertCountEquals(3)
// Limpiar y verificar
composeTestRule.onNodeWithTag("search_field")
.performTextClearance()
composeTestRule.onAllNodesWithTag("search_result")
.assertCountEquals(testItems.size)
}
Probando Gestos
@Test
fun imageGallery_supportsSwipeGestures() {
composeTestRule.setContent {
ImageGallery(images = imageList)
}
// Indicador de imagen actual
composeTestRule.onNodeWithTag("image_indicator")
.assertTextEquals("1 de 5")
// Realizar deslizamiento a la izquierda
composeTestRule.onNodeWithTag("image_pager")
.performTouchInput {
swipeLeft()
}
composeTestRule.onNodeWithTag("image_indicator")
.assertTextEquals("2 de 5")
}
Probando Navegación en Compose
Probando NavController
@Test
fun navigation_navigatesToDetailScreen() {
lateinit var navController: NavHostController
composeTestRule.setContent {
navController = rememberNavController()
AppNavGraph(navController = navController)
}
// Click en item para navegar
composeTestRule.onNodeWithTag("item_1")
.performClick()
// Verificar que navegación ocurrió
val currentRoute = navController.currentBackStackEntry?.destination?.route
assert(currentRoute == "detail/1")
// Verificar pantalla de detalle mostrada
composeTestRule.onNodeWithTag("detail_screen")
.assertIsDisplayed()
}
Probando Navegación Hacia Atrás
@Test
fun backButton_navigatesToPreviousScreen() {
lateinit var navController: NavHostController
composeTestRule.setContent {
navController = rememberNavController()
AppNavGraph(
navController = navController,
startDestination = "detail/123"
)
}
composeTestRule.onNodeWithTag("back_button")
.performClick()
assert(navController.currentDestination?.route == "home")
}
Mocking e Inyección de Dependencias
Mocking de Repositorios
class ProductScreenTest {
private lateinit var fakeRepository: FakeProductRepository
@Before
fun setup() {
fakeRepository = FakeProductRepository()
}
@Test
fun errorState_displaysErrorMessage() {
fakeRepository.setError("Error de red")
val viewModel = ProductViewModel(fakeRepository)
composeTestRule.setContent {
ProductScreen(viewModel = viewModel)
}
composeTestRule.onNodeWithTag("error_message")
.assertIsDisplayed()
.assertTextContains("Error de red")
}
}
class FakeProductRepository : ProductRepository {
private var error: String? = null
private var products: List<Product> = emptyList()
fun setError(message: String) {
error = message
}
override suspend fun getProducts(): Result<List<Product>> {
return error?.let { Result.failure(Exception(it)) }
?: Result.success(products)
}
}
Integración con Hilt Testing
@HiltAndroidTest
class DashboardScreenTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<MainActivity>()
@Inject
lateinit var repository: TestRepository
@Before
fun setup() {
hiltRule.inject()
}
@Test
fun dashboard_loadsDataSuccessfully() {
repository.setTestData(dashboardData)
composeTestRule.onNodeWithTag("dashboard_content")
.assertIsDisplayed()
}
}
Screenshot Testing para Compose
Integración con Paparazzi
class ButtonScreenshotTest {
@get:Rule
val paparazzi = Paparazzi(
deviceConfig = DeviceConfig.PIXEL_5,
theme = "android:Theme.Material3.DayNight"
)
@Test
fun primaryButton_defaultState() {
paparazzi.snapshot {
PrimaryButton(text = "Hacer Click", onClick = {})
}
}
@Test
fun primaryButton_disabledState() {
paparazzi.snapshot {
PrimaryButton(
text = "Hacer Click",
onClick = {},
enabled = false
)
}
}
}
Librería Shot para Android
@RunWith(ScreenshotTestRunner::class)
class ProductCardScreenshotTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun productCard_lightTheme() {
composeTestRule.setContent {
MyAppTheme(darkTheme = false) {
ProductCard(product = sampleProduct)
}
}
compareScreenshot(composeTestRule)
}
}
Pruebas de Rendimiento
Seguimiento de Recomposiciones
@Test
fun lazyList_minimizesRecompositions() {
var recompositionCount = 0
composeTestRule.setContent {
LaunchedEffect(Unit) {
snapshotFlow { recompositionCount }
.collect { println("Recomposiciones: $it") }
}
LazyColumn {
items(100) { index ->
RecomposeHighlighter {
recompositionCount++
ListItem(index = index)
}
}
}
}
// Desplazar y verificar mínimas recomposiciones
composeTestRule.onNodeWithTag("lazy_column")
.performScrollToIndex(50)
composeTestRule.waitForIdle()
// Solo items visibles deben recomponerse
assert(recompositionCount < 120) // ~20 items visibles buffer
}
Benchmark Testing
@RunWith(AndroidJUnit4::class)
class ComposeBenchmarkTest {
@get:Rule
val benchmarkRule = BenchmarkRule()
@Test
fun complexList_scrollPerformance() {
benchmarkRule.measureRepeated {
composeTestRule.setContent {
ComplexProductList(items = testData)
}
runWithTimingDisabled {
composeTestRule.waitForIdle()
}
composeTestRule.onNodeWithTag("product_list")
.performScrollToIndex(100)
}
}
}
Integración CI/CD
Configuración de GitHub Actions
name: Pruebas UI de Compose
on:
pull_request:
branches: [ main, develop ]
push:
branches: [ main ]
jobs:
instrumentation-tests:
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
- name: Configurar JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
- name: Dar permisos a gradlew
run: chmod +x gradlew
- name: Ejecutar pruebas Compose
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 33
target: google_apis
arch: x86_64
script: ./gradlew connectedAndroidTest
- name: Subir reportes de pruebas
if: always()
uses: actions/upload-artifact@v3
with:
name: test-reports
path: app/build/reports/androidTests/
Test Sharding
# Dividir pruebas entre múltiples dispositivos
./gradlew connectedAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.numShards=4 \
-Pandroid.testInstrumentationRunnerArguments.shardIndex=0
Mejores Prácticas y Patrones Comunes
1. Contenido Semántico sobre Test Tags
Evitar:
Button(
onClick = onClick,
modifier = Modifier.testTag("submit_button")
) {
Text("Enviar")
}
composeTestRule.onNodeWithTag("submit_button").performClick()
Preferir:
Button(
onClick = onClick,
modifier = Modifier.semantics {
contentDescription = "Enviar formulario"
}
) {
Text("Enviar")
}
composeTestRule.onNodeWithContentDescription("Enviar formulario").performClick()
2. Extraer Test Helpers
object ComposeTestHelpers {
fun SemanticsNodeInteraction.assertHasTextAndIsEnabled(text: String) {
assertTextEquals(text)
assertIsEnabled()
}
fun ComposeTestRule.waitForNodeWithTag(
tag: String,
timeoutMillis: Long = 3000
) {
waitUntil(timeoutMillis) {
onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty()
}
}
}
3. Aislamiento de Pruebas
@Test
fun isolatedTest_resetsState() {
composeTestRule.setContent {
// Estado fresco para cada prueba
var count by remember { mutableStateOf(0) }
CounterComponent(
count = count,
onIncrement = { count++ }
)
}
// Probar comportamiento específico sin efectos secundarios
}
4. Aserciones Significativas
Mala Práctica | Buena Práctica |
---|---|
Solo assertExists() | assertIsDisplayed() + verificación de contenido |
Múltiples performClick() sin esperas | performClick() + waitForIdle() |
Probar detalles de implementación | Probar comportamiento visible al usuario |
Delays hardcodeados | waitUntil() con condiciones |
5. Testing Orientado a Accesibilidad
@Test
fun form_isAccessible() {
composeTestRule.setContent {
RegistrationForm()
}
// Verificar propiedades semánticas
composeTestRule.onNodeWithTag("email_field")
.assertHasContentDescription("Correo electrónico")
.assert(hasImeAction(ImeAction.Next))
composeTestRule.onNodeWithTag("password_field")
.assertHasContentDescription("Contraseña")
.assert(hasImeAction(ImeAction.Done))
.assert(hasPasswordSemantics())
}
6. Gestión de Datos de Prueba
object TestData {
val sampleUser = User(
id = "test_123",
name = "Usuario de Prueba",
email = "prueba@example.com"
)
val productList = List(20) { index ->
Product(
id = "product_$index",
name = "Producto $index",
price = (index + 1) * 10.0
)
}
}
Conclusión
El testing en Jetpack Compose proporciona un framework robusto para garantizar la calidad de UI en aplicaciones Android modernas. Al aprovechar el árbol de semántica, ComposeTestRule y las APIs completas de testing, puedes escribir pruebas mantenibles que verifican tanto funcionalidad como experiencia de usuario. Puntos clave:
- Comprender el árbol de semántica como fundamento del testing en Compose
- Usar matchers apropiados para encontrar elementos UI de forma confiable
- Probar cambios de estado y recomposición para asegurar comportamiento reactivo de UI
- Implementar testing completo de interacciones para flujos de usuario
- Integrar screenshot testing para detección de regresión visual
- Optimizar para CI/CD con sharding adecuado y reportes
- Seguir prácticas de accesibilidad primero para testing inclusivo
A medida que Compose continúa evolucionando, mantenerse actualizado con las mejores prácticas de testing garantiza que tus aplicaciones Android permanezcan confiables, eficientes y accesibles para todos los usuarios.