Setup

// build.gradle (app)
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-test-manifest")

Los tests de Compose son instrumentadosCorren en un dispositivo o emulador real, igual que los tests de Espresso. Están en src/androidTest/. No pueden correr en la JVM porque necesitan el runtime de Android para renderizar composables.

ComposeTestRule — el setup básico

@RunWith(AndroidJUnit4::class)
class ProductoCardTest {

    // Para testear composables sin Activity completa:
    @get:Rule
    val composeTestRule = createComposeRule()

    // Para testear con una Activity específica:
    // @get:Rule
    // val composeTestRule = createAndroidComposeRule<MainActivity>()

    @Test
    fun `tarjeta de producto muestra nombre y precio`() {
        val producto = Producto(id = 1, nombre = "Mouse Logitech", precio = 5999.0)

        // Montar el composable a testear
        composeTestRule.setContent {
            MaterialTheme {  // siempre wrappear con el tema
                ProductoCard(producto = producto, onClick = {})
            }
        }

        // Verificar que el texto está presente
        composeTestRule.onNodeWithText("Mouse Logitech").assertIsDisplayed()
        composeTestRule.onNodeWithText("$5999.0").assertIsDisplayed()
    }
}

Finders — cómo encontrar nodos

// Por texto exacto
composeTestRule.onNodeWithText("Confirmar")
composeTestRule.onNodeWithText("Confirmar", ignoreCase = true)  // case insensitive
composeTestRule.onNodeWithText("Confirm", substring = true)     // substring

// Por contentDescription (accesibilidad)
composeTestRule.onNodeWithContentDescription("Cerrar")

// Por tag de test
composeTestRule.onNodeWithTag("boton-enviar")
// En el composable: Modifier.testTag("boton-enviar")

// Por rol de semántica
composeTestRule.onNode(hasRole(Role.Button))
composeTestRule.onNode(isEnabled())
composeTestRule.onNode(isFocused())

// Combinando matchers
composeTestRule.onNode(
    hasText("Confirmar") and isEnabled()
)

// Múltiples nodos que coinciden
composeTestRule.onAllNodesWithText("Eliminar")  // retorna SemanticsNodeInteractionCollection
composeTestRule.onAllNodesWithText("Eliminar")[0]  // el primero

Assertions

// Visibilidad
composeTestRule.onNodeWithText("Error").assertIsDisplayed()
composeTestRule.onNodeWithText("Loader").assertDoesNotExist()
composeTestRule.onNodeWithText("Oculto").assertIsNotDisplayed()  // existe pero no visible

// Estado
composeTestRule.onNodeWithText("Enviar").assertIsEnabled()
composeTestRule.onNodeWithText("Enviar").assertIsNotEnabled()
composeTestRule.onNode(hasRole(Role.Checkbox)).assertIsOn()
composeTestRule.onNode(hasRole(Role.Checkbox)).assertIsOff()

// Texto exacto
composeTestRule.onNodeWithTag("precio").assertTextEquals("$5999.0")
composeTestRule.onNodeWithTag("titulo").assertTextContains("Mouse", substring = true)

// Verificar que un nodo tiene un hijo con cierto texto
composeTestRule.onNodeWithTag("card-producto").assert(
    hasAnyChild(hasText("Mouse Logitech"))
)

// Verificar cantidad de elementos en una lista
composeTestRule.onAllNodesWithTag("item-lista").assertCountEquals(5)

Acciones

// Click
composeTestRule.onNodeWithText("Confirmar").performClick()
composeTestRule.onNodeWithText("Item").performClick()

// Input de texto
composeTestRule.onNodeWithTag("campo-email")
    .performTextInput("[email protected]")

composeTestRule.onNodeWithTag("campo-busqueda")
    .performTextClearance()  // limpiar el campo
    .performTextInput("Android")

// Scroll
composeTestRule.onNodeWithTag("lista-productos")
    .performScrollToIndex(10)  // scrollear hasta el índice 10

composeTestRule.onNodeWithTag("lista-productos")
    .performScrollToNode(hasText("Monitor ASUS"))

// Gestos
composeTestRule.onNodeWithTag("card-swipeable")
    .performTouchInput { swipeLeft() }

composeTestRule.onNodeWithTag("slider")
    .performTouchInput { swipeRight(endX = centerX + 200f) }

// Después de una acción, esperar que las animaciones terminen
composeTestRule.mainClock.advanceTimeBy(500)  // avanzar 500ms
// O esperar idle:
composeTestRule.waitForIdle()

Semántica custom — para testear mejor

// Definir una clave semántica custom
val PrecioSemantic = SemanticsPropertyKey<String>("precio")
var SemanticsPropertyReceiver.precio by PrecioSemantic

// Usarla en el composable
@Composable
fun PrecioText(valor: Double) {
    Text(
        text = "$$valor",
        modifier = Modifier.semantics {
            precio = valor.toString()  // expone el precio como valor semántico
            // Puede ser más preciso que buscar por texto si el formato cambia
        }
    )
}

// Encontrar por propiedad semántica custom
composeTestRule.onNode(
    SemanticsMatcher.expectValue(PrecioSemantic, "5999.0")
).assertIsDisplayed()

// Agregar testTag en el composable
@Composable
fun ProductoCard(producto: Producto, onClick: () -> Unit) {
    Card(
        modifier = Modifier
            .testTag("producto-card-${producto.id}")  // tag único por item
            .clickable { onClick() }
    ) {
        Text(producto.nombre)
    }
}

Testear estados de UI completos

// Testear los distintos estados de una pantalla con un fake ViewModel
@RunWith(AndroidJUnit4::class)
class ProductosPantallaTest {

    @get:Rule val composeTestRule = createComposeRule()

    @Test
    fun `estado loading muestra progress indicator`() {
        composeTestRule.setContent {
            MaterialTheme {
                ProductosPantalla(
                    uiState = ProductosUiState(isLoading = true)
                )
            }
        }
        composeTestRule.onNode(isRoot()).onChild()
            .assert(hasContentDescription("Cargando"))
        // O buscar por tag si el CircularProgressIndicator tiene uno
        composeTestRule.onNodeWithTag("progress-indicator").assertIsDisplayed()
    }

    @Test
    fun `estado success muestra lista de productos`() {
        val productos = listOf(
            Producto(1, "Mouse", 2000.0),
            Producto(2, "Teclado", 5000.0)
        )
        composeTestRule.setContent {
            MaterialTheme {
                ProductosPantalla(
                    uiState = ProductosUiState(productos = productos)
                )
            }
        }
        composeTestRule.onNodeWithText("Mouse").assertIsDisplayed()
        composeTestRule.onNodeWithText("Teclado").assertIsDisplayed()
        composeTestRule.onNodeWithTag("progress-indicator").assertDoesNotExist()
    }

    @Test
    fun `click en producto llama al callback`() {
        var productoClickeado: Producto? = null
        val producto = Producto(1, "Mouse", 2000.0)

        composeTestRule.setContent {
            MaterialTheme {
                ProductoCard(
                    producto = producto,
                    onClick = { productoClickeado = it }
                )
            }
        }

        composeTestRule.onNodeWithText("Mouse").performClick()
        assertThat(productoClickeado).isEqualTo(producto)
    }
}

Testear animaciones

@Test
fun `el panel aparece cuando visible es true`() {
    var visible by mutableStateOf(false)

    composeTestRule.setContent {
        MaterialTheme {
            AnimatedVisibility(visible = visible) {
                Text("Panel deslizante", modifier = Modifier.testTag("panel"))
            }
        }
    }

    // Inicialmente no visible
    composeTestRule.onNodeWithTag("panel").assertDoesNotExist()

    // Mostrar el panel
    visible = true

    // Las animaciones de Compose son controlables en tests
    // mainClock.autoAdvance = false detiene el tiempo
    // mainClock.advanceTimeBy(ms) lo avanza manualmente
    composeTestRule.mainClock.advanceTimeBy(500)  // dejar que la animación corra

    composeTestRule.onNodeWithTag("panel").assertIsDisplayed()
}