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()
}