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

ComponentePropósitoUbicación
ui-test-junit4APIs principales y ComposeTestRuleandroidTest
ui-test-manifestRequerido para builds de debugdebug
JUnit 4Test runner y asercionesandroidTest
EspressoOpcional, para testing de interoperabilidad con ViewsandroidTest

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 RuleCaso de UsoMétodo de Creación
createComposeRule()Probar composables individualesConfiguración manual de contenido
createAndroidComposeRule<Activity>()Pruebas con contexto de ActivityLanzamiento automático de Activity
createEmptyComposeRule()Escenarios avanzados con configuración personalizadaControl 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ácticaBuena Práctica
Solo assertExists()assertIsDisplayed() + verificación de contenido
Múltiples performClick() sin esperasperformClick() + waitForIdle()
Probar detalles de implementaciónProbar comportamiento visible al usuario
Delays hardcodeadoswaitUntil() 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.