1. Introducción y configuración
En este codelab, aprenderás a realizar pruebas de las IUs que crees con Jetpack Compose. Escribirás tus primeras pruebas, a la vez que aprendes sobre las pruebas aisladas, las pruebas de depuración, los árboles semánticos y la sincronización.
Requisitos
- Versión más reciente de Android Studio
- Conocimientos sobre Kotlin
- Conocimientos básicos sobre Compose (como la anotación
@Composable
) - Conocimientos básicos sobre modificadores
- Opcional: Antes de este codelab, considera realizar el codelab de los principios básicos de Jetpack Compose.
Cómo consultar el código para este codelab (Rally)
Usarás el estudio de Material sobre Rally como base para este codelab. Lo encontrarás en el repositorio de GitHub android-compose-codelabs. Para clonarlo, ejecuta lo siguiente:
git clone https://github.com/android/codelab-android-compose.git
Una vez que lo descargues, abre el proyecto TestingCodelab
.
Como alternativa, puedes descargar dos archivos ZIP:
Abre la carpeta TestingCodelab, que contiene una app que se llama Rally.
Cómo examinar la estructura del proyecto
Las pruebas de Compose son pruebas instrumentadas, lo que significa que requieren un dispositivo (dispositivo físico o emulador) para ejecutarse.
Rally ya incluye algunas pruebas instrumentadas de IU. Puedes encontrarlas en el conjunto de orígenes androidTest:
Este es el directorio en el que colocarás las pruebas nuevas. No dudes en consultar el archivo AnimatingCircleTests.kt
para obtener información sobre cómo es una prueba de Compose.
Rally ya está configurado, pero todo lo que necesitas para habilitar las pruebas de Compose en un proyecto nuevo son las dependencias de prueba en el archivo build.gradle
del módulo relevante, que se muestran a continuación:
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$version"
debugImplementation "androidx.compose.ui:ui-test-manifest:$rootProject.composeVersion"
No dudes en ejecutar la app y familiarizarte con ella.
2. Qué debes probar
Nos enfocaremos en la barra de pestañas de Rally, que contiene una fila de pestañas (Overview, Accounts y Bills). En este contexto, se ve de la siguiente manera:
En este codelab, realizarás pruebas de la IU de la barra.
Estas pruebas podrían implicar lo siguiente:
- Probar que las pestañas muestren el ícono y el texto deseados.
- Probar que la animación coincida con la especificación
- Probar que los eventos activados de navegación sean correctos
- Probar la posición y las distancias de los elementos de la IU en diferentes estados
- Tomar una captura de pantalla de la barra y compararla con una captura anterior
No se establecen reglas exactas sobre cuánto o cómo realizar pruebas de un componente. Podrías realizar todas las pruebas que se mencionaron anteriormente. En este codelab, probarás que la lógica del estado sea correcta. Para ello, verifica lo siguiente:
- Si una pestaña muestra su etiqueta solo cuando está seleccionada
- Si la pantalla activa define la pestaña que está seleccionada
3. Crea una prueba simple de IU
Cómo crear el archivo TopAppBarTest
Crea un archivo nuevo en la misma carpeta que AnimatingCircleTests.kt
(app/src/androidTest/com/example/compose/rally
) y llámalo TopAppBarTest.kt
.
Compose incluye un elemento ComposeTestRule
que puedes obtener mediante una llamada a createComposeRule()
. Esta regla te permite realizar pruebas del contenido de Compose e interactuar con este.
Cómo agregar el elemento ComposeTestRule
package com.example.compose.rally
import androidx.compose.ui.test.junit4.createComposeRule
import org.junit.Rule
class TopAppBarTest {
@get:Rule
val composeTestRule = createComposeRule()
// TODO: Add tests
}
Cómo realizar pruebas de forma aislada
En una prueba de Compose, podemos iniciar la actividad principal de la app del mismo modo a cómo lo harías en View de Android, por ejemplo, con Espresso. Puedes hacerlo con createAndroidComposeRule
.
// Don't copy this over
@get:Rule
val composeTestRule = createAndroidComposeRule(RallyActivity::class.java)
Sin embargo, con Compose, para simplificar el proceso de manera considerable, podemos realizar pruebas de un componente de forma aislada. Puedes elegir el contenido de la IU de Compose que deseas usar en la prueba. Para ello, puedes usar el método setContent
del elemento ComposeTestRule
y llamarlo en cualquier lugar (pero solo una vez).
// Don't copy this over
class TopAppBarTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun myTest() {
composeTestRule.setContent {
Text("You can set any Compose content!")
}
}
}
Como queremos realizar pruebas de TopAppBar, enfoquémonos en ese elemento. Llama a RallyTopAppBar
dentro de setContent
y permite que Android Studio complete los nombres de los parámetros.
import androidx.compose.ui.test.junit4.createComposeRule
import com.example.compose.rally.ui.components.RallyTopAppBar
import org.junit.Rule
import org.junit.Test
class TopAppBarTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun rallyTopAppBarTest() {
composeTestRule.setContent {
RallyTopAppBar(
allScreens = ,
onTabSelected = { /*TODO*/ },
currentScreen =
)
}
}
}
La importancia de un elemento que admite composición y que se puede probar
RallyTopAppBar
toma tres parámetros que son fáciles de proporcionar para que podamos pasar los datos falsos que controlamos. Por ejemplo:
@Test
fun rallyTopAppBarTest() {
val allScreens = RallyScreen.values().toList()
composeTestRule.setContent {
RallyTopAppBar(
allScreens = allScreens,
onTabSelected = { },
currentScreen = RallyScreen.Accounts
)
}
Thread.sleep(5000)
}
También agregamos un elemento sleep()
para que puedas ver lo que está sucediendo. Haz clic con el botón derecho en rallyTopAppBarTest
y, luego, haz clic en "Run rallyTopAppBarTest()...".
La prueba muestra la barra superior de la aplicación (durante 5 segundos), pero no tiene la apariencia que esperábamos: tiene un tema claro.
El motivo es que la barra se crea con componentes de Material, que se espera que estén dentro un objeto MaterialTheme o, de lo contrario, se recurre a los colores de estilo de "referencia".
MaterialTheme
cuenta con una buena configuración predeterminada para que no falle. Como no realizaremos pruebas del tema ni tomaremos capturas de pantalla, podemos omitirlo y trabajar con el tema claro predeterminado. Si deseas, puedes unir RallyTopAppBar
con RallyTheme
para corregirlo.
Cómo verificar que la pestaña esté seleccionada
Mediante la regla de prueba, se buscan elementos de la IU, se verifican sus propiedades, y se realizan acciones, con el siguiente patrón:
composeTestRule{.finder}{.assertion}{.action}
En esta prueba, buscarás la palabra "Accounts" con el objeto de verificar que se muestre la etiqueta para la pestaña seleccionada.
Una buena manera de comprender qué herramientas están disponibles es usar la hoja de referencia para pruebas de Compose o la documentación de referencia del paquete de prueba. Encuentra buscadores y aserciones que puedan ayudarnos en nuestra situación. Por ejemplo: onNodeWithText
, onNodeWithContentDescription
, isSelected
, hasContentDescription
, assertIsSelected
…
Cada pestaña tiene una descripción diferente de contenido:
- Descripción general
- Cuentas
- Facturas
Con esta información, reemplaza el elemento Thread.sleep(5000)
por una sentencia que busque una descripción de contenido y confirme que existe:
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.onNodeWithContentDescription
...
@Test
fun rallyTopAppBarTest_currentTabSelected() {
val allScreens = RallyScreen.values().toList()
composeTestRule.setContent {
RallyTopAppBar(
allScreens = allScreens,
onTabSelected = { },
currentScreen = RallyScreen.Accounts
)
}
composeTestRule
.onNodeWithContentDescription(RallyScreen.Accounts.name)
.assertIsSelected()
}
Ahora, vuelve a ejecutar la prueba. Deberías ver una prueba verde:
¡Felicitaciones! Escribiste tu primera prueba de Compose. Aprendiste a realizar pruebas de forma aislada, y a usar buscadores y aserciones.
Fue sencillo, pero se necesitaron conocimientos previos sobre el componente (las descripciones de contenido y la propiedad selected). En el siguiente paso, aprenderás a inspeccionar qué propiedades están disponibles.
4. Pruebas de depuración
En este paso, verificarás que se muestre en mayúsculas la etiqueta de la pestaña actual.
Una solución posible sería intentar encontrar el texto y confirmar que existe:
import androidx.compose.ui.test.onNodeWithText
...
@Test
fun rallyTopAppBarTest_currentLabelExists() {
val allScreens = RallyScreen.values().toList()
composeTestRule.setContent {
RallyTopAppBar(
allScreens = allScreens,
onTabSelected = { },
currentScreen = RallyScreen.Accounts
)
}
composeTestRule
.onNodeWithText(RallyScreen.Accounts.name.uppercase())
.assertExists()
}
Sin embargo, si ejecutas la prueba, fallará. 😱
En este paso, aprenderás a depurar esta prueba con el árbol semántico.
Árbol semántico
Las pruebas de Compose usan una estructura que se denomina árbol semántico para buscar elementos en la pantalla y leer sus propiedades. Además, es la estructura que usan los servicios de accesibilidad, ya que se diseñaron para que los lea un servicio, como TalkBack.
Puedes imprimir el árbol semántico con la función printToLog
en un nodo. Agrega una línea nueva a la prueba:
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.printToLog
...
fun rallyTopAppBarTest_currentLabelExists() {
val allScreens = RallyScreen.values().toList()
composeTestRule.setContent {
RallyTopAppBar(
allScreens = allScreens,
onTabSelected = { },
currentScreen = RallyScreen.Accounts
)
}
composeTestRule.onRoot().printToLog("currentLabelExists")
composeTestRule
.onNodeWithText(RallyScreen.Accounts.name.uppercase())
.assertExists() // Still fails
}
Ahora, ejecuta la prueba y consulta Logcat en Android Studio (puedes buscar currentLabelExists
).
...com.example.compose.rally D/currentLabelExists: printToLog:
Printing with useUnmergedTree = 'false'
Node #1 at (l=0.0, t=63.0, r=1080.0, b=210.0)px
|-Node #2 at (l=0.0, t=63.0, r=1080.0, b=210.0)px
[SelectableGroup]
MergeDescendants = 'true'
|-Node #3 at (l=42.0, t=105.0, r=105.0, b=168.0)px
| Role = 'Tab'
| Selected = 'false'
| StateDescription = 'Not selected'
| ContentDescription = 'Overview'
| Actions = [OnClick]
| MergeDescendants = 'true'
| ClearAndSetSemantics = 'true'
|-Node #6 at (l=189.0, t=105.0, r=468.0, b=168.0)px
| Role = 'Tab'
| Selected = 'true'
| StateDescription = 'Selected'
| ContentDescription = 'Accounts'
| Actions = [OnClick]
| MergeDescendants = 'true'
| ClearAndSetSemantics = 'true'
|-Node #11 at (l=552.0, t=105.0, r=615.0, b=168.0)px
Role = 'Tab'
Selected = 'false'
StateDescription = 'Not selected'
ContentDescription = 'Bills'
Actions = [OnClick]
MergeDescendants = 'true'
ClearAndSetSemantics = 'true'
Si observas el árbol semántico, puedes ver un objeto SelectableGroup
con 3 elementos secundarios, que son las pestañas de la barra superior de la aplicación. Resulta que no hay una propiedad text
con un valor de "ACCOUNTS", y este es el motivo por el que la prueba falla. Sin embargo, hay una descripción de contenido para cada pestaña. Puedes verificar cómo se configura esta propiedad en el elemento RallyTab
que admite composición dentro de RallyTopAppBar.kt
:
private fun RallyTab(text: String...)
...
Modifier
.clearAndSetSemantics { contentDescription = text }
Este modificador borra las propiedades de los elementos subordinados y configura su propia descripción de contenido; por este motivo, ves "Accounts" en lugar de "ACCOUNTS".
Reemplaza el buscador onNodeWithText
por onNodeWithContentDescription
y vuelve a ejecutar la prueba:
fun rallyTopAppBarTest_currentLabelExists() {
val allScreens = RallyScreen.values().toList()
composeTestRule.setContent {
RallyTopAppBar(
allScreens = allScreens,
onTabSelected = { },
currentScreen = RallyScreen.Accounts
)
}
composeTestRule
.onNodeWithContentDescription(RallyScreen.Accounts.name)
.assertExists()
}
¡Felicitaciones! Corregiste la prueba y aprendiste sobre el elemento ComposeTestRule
, las pruebas aisladas, los buscadores, las aserciones y la depuración con el árbol semántico.
Sin embargo, hay malas noticias: esta prueba no es muy útil. Si observas el árbol semántico detenidamente, verás las descripciones de contenido de las tres pestañas, sin importar si están seleccionadas. Debemos profundizar en este tema.
5. Árboles semánticos combinados y separados
El árbol semántico siempre intenta ser lo más compacto posible y muestra solo la información relevante.
Por ejemplo, en el elemento TopAppBar
, no es necesario que los íconos y las etiquetas sean nodos diferentes. Observa el nodo "Overview":
|-Node #3 at (l=42.0, t=105.0, r=105.0, b=168.0)px
| Role = 'Tab'
| Selected = 'false'
| StateDescription = 'Not selected'
| ContentDescription = 'Overview'
| Actions = [OnClick]
| MergeDescendants = 'true'
| ClearAndSetSemantics = 'true'
Este nodo tiene propiedades (como Selected
y Role
) que se definen, de manera específica, para un componente selectable
y una descripción de contenido para toda la pestaña. Estas son propiedades de alto nivel y muy útiles para las pruebas simples. Los detalles sobre el ícono o el texto serían redundantes, por lo que no se muestran.
Compose expone estas propiedades semánticas automáticamente en algunos elementos que admiten composición, como Text
. Además, puedes personalizarlos y combinarlos para representar un solo componente que se conforme de uno o varios elementos subordinados. Por ejemplo: puedes representar un objeto Button
que contenga un elemento Text
que admita composición. La propiedad MergeDescendants = 'true'
nos indica que este nodo tiene elementos subordinados, pero se combinaron en este nodo. Con frecuencia, en las pruebas, necesitamos acceder a todos los nodos.
Para verificar si se muestra o no el elemento Text
dentro de la pestaña, podemos consultar el árbol semántico separado que pasa el objeto useUnmergedTree = true
al buscador onRoot
.
@Test
fun rallyTopAppBarTest_currentLabelExists() {
val allScreens = RallyScreen.values().toList()
composeTestRule.setContent {
RallyTopAppBar(
allScreens = allScreens,
onTabSelected = { },
currentScreen = RallyScreen.Accounts
)
}
composeTestRule.onRoot(useUnmergedTree = true).printToLog("currentLabelExists")
}
Ahora, el resultado en Logcat es un poco más largo:
Printing with useUnmergedTree = 'true'
Node #1 at (l=0.0, t=63.0, r=1080.0, b=210.0)px
|-Node #2 at (l=0.0, t=63.0, r=1080.0, b=210.0)px
[SelectableGroup]
MergeDescendants = 'true'
|-Node #3 at (l=42.0, t=105.0, r=105.0, b=168.0)px
| Role = 'Tab'
| Selected = 'false'
| StateDescription = 'Not selected'
| ContentDescription = 'Overview'
| Actions = [OnClick]
| MergeDescendants = 'true'
| ClearAndSetSemantics = 'true'
|-Node #6 at (l=189.0, t=105.0, r=468.0, b=168.0)px
| Role = 'Tab'
| Selected = 'true'
| StateDescription = 'Selected'
| ContentDescription = 'Accounts'
| Actions = [OnClick]
| MergeDescendants = 'true'
| ClearAndSetSemantics = 'true'
| |-Node #9 at (l=284.0, t=105.0, r=468.0, b=154.0)px
| Text = 'ACCOUNTS'
| Actions = [GetTextLayoutResult]
|-Node #11 at (l=552.0, t=105.0, r=615.0, b=168.0)px
Role = 'Tab'
Selected = 'false'
StateDescription = 'Not selected'
ContentDescription = 'Bills'
Actions = [OnClick]
MergeDescendants = 'true'
ClearAndSetSemantics = 'true'
El nodo n.º 3 todavía no tiene elementos subordinados:
|-Node #3 at (l=42.0, t=105.0, r=105.0, b=168.0)px
| Role = 'Tab'
| Selected = 'false'
| StateDescription = 'Not selected'
| ContentDescription = 'Overview'
| Actions = [OnClick]
| MergeDescendants = 'true'
| ClearAndSetSemantics = 'true'
Sin embargo, el nodo n.º 6, la pestaña seleccionada, tiene uno; y ahora podemos ver la propiedad "Text":
|-Node #6 at (l=189.0, t=105.0, r=468.0, b=168.0)px
| Role = 'Tab'
| Selected = 'true'
| StateDescription = 'Selected'
| ContentDescription = 'Accounts'
| Actions = [OnClick]
| MergeDescendants = 'true'
| |-Node #9 at (l=284.0, t=105.0, r=468.0, b=154.0)px
| Text = 'ACCOUNTS'
| Actions = [GetTextLayoutResult]
Para verificar el comportamiento correcto como deseamos, escribirás un comparador que encuentre un nodo con el texto "ACCOUNTS" cuyo elemento superior sea un nodo con la descripción de contenido "Accounts".
Vuelve a consultar la hoja de referencia para pruebas de Compose e intenta encontrar una manera de escribir ese comparador. Ten en cuenta que puedes usar operadores booleanos, como and
y or
, con comparadores.
Todos los buscadores tienen un parámetro que se llama useUnmergedTree
. Establécelo en true
para usar el árbol separado.
Intenta escribir la prueba sin consultar la solución.
Solución
import androidx.compose.ui.test.hasParent
import androidx.compose.ui.test.hasText
...
@Test
fun rallyTopAppBarTest_currentLabelExists() {
val allScreens = RallyScreen.values().toList()
composeTestRule.setContent {
RallyTopAppBar(
allScreens = allScreens,
onTabSelected = { },
currentScreen = RallyScreen.Accounts
)
}
composeTestRule
.onNode(
hasText(RallyScreen.Accounts.name.uppercase()) and
hasParent(
hasContentDescription(RallyScreen.Accounts.name)
),
useUnmergedTree = true
)
.assertExists()
}
Ejecuta la prueba:
¡Felicitaciones! En este paso, aprendiste sobre la combinación de propiedades, y los árboles semánticos combinados y separados.
6. Sincronización
Todas las pruebas que escribas deben estar sincronizadas, de manera correcta, con el sujeto de prueba. Por ejemplo, cuando usas un buscador, como onNodeWithText
, la prueba espera hasta que la app esté inactiva antes de consultar el árbol semántico. Sin la sincronización, las pruebas pueden buscar elementos antes de que se muestren o podrían esperarse de manera innecesaria.
Para este paso, usaremos la pantalla Overview, que se ve de la siguiente manera cuando ejecutas la app:
Observa la animación intermitente y repetitiva de la tarjeta Alerts (Alertas), que capta la atención a este elemento.
Crea otra clase de prueba con el nombre OverviewScreenTest
y agrega el siguiente contenido:
package com.example.compose.rally
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import com.example.compose.rally.ui.overview.OverviewBody
import org.junit.Rule
import org.junit.Test
class OverviewScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun overviewScreen_alertsDisplayed() {
composeTestRule.setContent {
OverviewBody()
}
composeTestRule
.onNodeWithText("Alerts")
.assertIsDisplayed()
}
}
Si ejecutas esta prueba, te darás cuenta de que nunca se completa (se agota el tiempo de espera después de 30 segundos).
El error te advierte lo siguiente:
androidx.compose.ui.test.junit4.android.ComposeNotIdleException: Idling resource timed out: possibly due to compose being busy.
IdlingResourceRegistry has the following idling resources registered:
- [busy] androidx.compose.ui.test.junit4.android.ComposeIdlingResource@d075f91
Básicamente, te indica que Compose está en funcionamiento de forma permanente, por lo que no hay manera de sincronizar la app con la prueba.
Es posible ya hayas deducido que el problema es la animación infinita e intermitente. La app nunca estará inactiva, por lo que no se puede continuar con la prueba.
Observemos la implementación de la animación infinita:
app/src/main/java/com/example/compose/rally/ui/overview/OverviewBody.kt
var currentTargetElevation by remember { mutableStateOf(1.dp) }
LaunchedEffect(Unit) {
// Start the animation
currentTargetElevation = 8.dp
}
val animatedElevation = animateDpAsState(
targetValue = currentTargetElevation,
animationSpec = tween(durationMillis = 500),
finishedListener = {
currentTargetElevation = if (currentTargetElevation > 4.dp) {
1.dp
} else {
8.dp
}
}
)
Card(elevation = animatedElevation.value) { ... }
En esencia, este código espera a que finalice una animación (finishedListener
) y, luego, la vuelve a ejecutar.
Un enfoque para corregir esta prueba sería inhabilitar las animaciones en las opciones para desarrolladores. Es una de las maneras más aceptadas para solucionar esta prueba en View
.
En Compose, las API de animación se diseñaron teniendo en cuenta la capacidad de prueba, por lo que el problema se puede corregir con la API correcta. En lugar de reiniciar la animación animateDpAsState
, podemos usar animaciones infinitas.
Reemplaza el código en OverviewScreen
por la API correcta:
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.animateValue
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.ui.unit.Dp
...
val infiniteElevationAnimation = rememberInfiniteTransition()
val animatedElevation: Dp by infiniteElevationAnimation.animateValue(
initialValue = 1.dp,
targetValue = 8.dp,
typeConverter = Dp.VectorConverter,
animationSpec = infiniteRepeatable(
animation = tween(500),
repeatMode = RepeatMode.Reverse
)
)
Card(elevation = animatedElevation) {
Si ejecutas la prueba, ahora se completará:
¡Felicitaciones! En este paso, aprendiste sobre la sincronización y la manera en que las animaciones pueden afectar las pruebas.
7. Ejercicio opcional
En este paso, usarás una acción (consulta la hoja de referencia para pruebas) para verificar si, con clics en las pestañas diferentes de RallyTopAppBar
, se cambia la selección.
Sugerencias:
- El alcance de la prueba debe incluir el estado, que le pertenece a
RallyApp
. - Verifica el estado en lugar del comportamiento. Usa aserciones sobre el estado de la IU en lugar de depender en qué objetos se llamaron y cómo se llamaron.
No se brinda una solución para este ejercicio.
8. Próximos pasos
¡Felicitaciones! Completaste el codelab Pruebas en Jetpack Compose. Ahora, cuentas con los componentes básicos que puedes usar con el objeto de crear una buena estrategia de pruebas para tus IU de Compose.
Si quieres obtener más información sobre las pruebas y Compose, consulta estos recursos:
- La documentación sobre pruebas incluye más información acerca de buscadores, aserciones, acciones y comparadores, mecanismos de sincronización, manipulación de tiempo, etc.
- Agrega a favoritos la hoja de referencia para pruebas.
- El ejemplo de Rally incluye una clase simple de prueba de captura de pantalla. Explora el archivo
AnimatingCircleTests.kt
para obtener más información. - Si quieres obtener instrucciones generales sobre cómo realizar pruebas de apps para Android, puedes seguir estos tres codelabs:
- Aspectos básicos de las pruebas
- Inyección de dependencias y dobles de prueba
- Encuesta sobre temas de prueba
- El repositorio de muestras de Compose en GitHub tiene varias apps con pruebas de IU.
- La ruta de aprendizaje de Jetpack Compose muestra una lista de recursos para comenzar a usar Compose.
¡Éxitos con la prueba!