Cómo probar tu diseño de Compose

Las pruebas de una IU o de pantallas se usan para verificar el comportamiento correcto del código de Compose y mejorar la calidad de la app mediante la detección de errores al comienzo del proceso de desarrollo.

Compose brinda un conjunto de API de prueba para buscar elementos, verificar sus atributos y realizar acciones del usuario. También incluye características avanzadas, como la manipulación de tiempo.

Semántica

Las pruebas de IU en Compose usan la semántica para interactuar con la jerarquía de la IU. La semántica, como su nombre lo indica, le da significado a una parte de la IU. En este contexto, una "parte de la IU" (o elemento) puede hacer referencia a todo, desde un único elemento que admite composición hasta una pantalla completa. Se elabora el árbol semántico a lo largo de la jerarquía de la IU, y este la describe.

Diagrama que muestra un diseño de IU típico y la forma en que este se asignaría al árbol semántico correspondiente

Figura 1: Una jerarquía de IU típica y su árbol semántico

El framework semántico se usa principalmente para fines de accesibilidad, por lo que las pruebas aprovechan la información que muestra la semántica sobre la jerarquía de la IU. Los desarrolladores deciden qué exponer y en qué medida.

Botón que contiene un gráfico y texto

Figura 2: Botón típico que contiene un ícono y texto

Por ejemplo, en un botón como este con un ícono y un elemento de texto, el árbol semántico predeterminado solo contiene la etiqueta de texto "Like" ("Me gusta"). Esto se debe a que algunos elementos que admiten composición, como Text, ya exponen algunas propiedades al árbol semántico. Puedes agregar propiedades al árbol a través de un Modifier.

MyButton(
    modifier = Modifier.semantics { contentDescription = "Add to favorites" }
)

Configuración

En esta sección se describe cómo configurar el módulo para probar el código de Compose.

Primero, agrega las siguientes dependencias al archivo build.gradle del módulo que contiene las pruebas de tu IU:

// Test rules and transitive dependencies:
androidTestImplementation("androidx.compose.ui:ui-test-junit4:$compose_version")
// Needed for createAndroidComposeRule, but not createComposeRule:
debugImplementation("androidx.compose.ui:ui-test-manifest:$compose_version")

En este módulo, se incluye una ComposeTestRule y una implementación para Android llamada AndroidComposeTestRule. Con esta regla, puedes establecer el contenido de Compose o acceder a la actividad. La prueba típica de IU para Compose se ve de la siguiente manera:

// file: app/src/androidTest/java/com/package/MyComposeTest.kt

class MyComposeTest {

    @get:Rule
    val composeTestRule = createComposeRule()
    // use createAndroidComposeRule<YourActivity>() if you need access to
    // an activity

    @Test
    fun myTest() {
        // Start the app
        composeTestRule.setContent {
            MyAppTheme {
                MainScreen(uiState = fakeUiState, /*...*/)
            }
        }

        composeTestRule.onNodeWithText("Continue").performClick()

        composeTestRule.onNodeWithText("Welcome").assertIsDisplayed()
    }
}

API de prueba

Existen tres formas principales de interactuar con elementos:

  • Los buscadores te permiten seleccionar uno o varios elementos (o nodos en el árbol semántico) para realizar aserciones o acciones sobre ellos.
  • Las aserciones se usan para verificar que los elementos existan o tengan determinados atributos.
  • Las acciones insertan eventos simulados del usuario en los elementos, como clics u otros gestos.

Algunas de estas API aceptan SemanticsMatcher para hacer referencia a uno o más nodos en el árbol semántico.

Buscadores

Puedes usar onNode y onAllNodes a fin de seleccionar uno o varios nodos, respectivamente, pero también puedes usar buscadores prácticos para las búsquedas más frecuentes, como onNodeWithText, onNodeWithContentDescription, etc. Puedes consultar la lista completa en la hoja de referencia para pruebas de Compose.

Seleccionar un solo nodo

composeTestRule.onNode(<<SemanticsMatcher>>, useUnmergedTree = false): SemanticsNodeInteraction
// Example
composeTestRule
    .onNode(hasText("Button")) // Equivalent to onNodeWithText("Button")

Para seleccionar varios nodos

composeTestRule
    .onAllNodes(<<SemanticsMatcher>>): SemanticsNodeInteractionCollection
// Example
composeTestRule
    .onAllNodes(hasText("Button")) // Equivalent to onAllNodesWithText("Button")

Cómo usar el árbol separado

Algunos nodos combinan la información semántica de sus elementos secundarios. Por ejemplo, un botón con dos elementos de texto combina sus etiquetas:

MyButton {
    Text("Hello")
    Text("World")
}

En una prueba, podemos usar printToLog() para mostrar el árbol semántico:

composeTestRule.onRoot().printToLog("TAG")

Este código muestra el siguiente resultado:

Node #1 at (...)px
 |-Node #2 at (...)px
   Role = 'Button'
   Text = '[Hello, World]'
   Actions = [OnClick, GetTextLayoutResult]
   MergeDescendants = 'true'

Si necesitas vincular un nodo de lo que sería el árbol separado, puedes configurar useUnmergedTree como true:

composeTestRule.onRoot(useUnmergedTree = true).printToLog("TAG")

Este código muestra el siguiente resultado:

Node #1 at (...)px
 |-Node #2 at (...)px
   OnClick = '...'
   MergeDescendants = 'true'
    |-Node #3 at (...)px
    | Text = '[Hello]'
    |-Node #5 at (83.0, 86.0, 191.0, 135.0)px
      Text = '[World]'

El parámetro useUnmergedTree está disponible en todos los buscadores. Por ejemplo, aquí se usa en un buscador onNodeWithText.

composeTestRule
    .onNodeWithText("World", useUnmergedTree = true).assertIsDisplayed()

Aserciones

Para verificar las aserciones, llama a assert() en el objeto SemanticsNodeInteraction que muestra un buscador con uno o varios comparadores:

// Single matcher:
composeTestRule
    .onNode(matcher)
    .assert(hasText("Button")) // hasText is a SemanticsMatcher

// Multiple matchers can use and / or
composeTestRule
    .onNode(matcher).assert(hasText("Button") or hasText("Button2"))

También puedes usar funciones prácticas para las aserciones más frecuentes, como assertExists, assertIsDisplayed, assertTextEquals, etc. Puedes consultar la lista completa en la hoja de referencia para pruebas de Compose.

También existen funciones para verificar aserciones en una colección de nodos:

// Check number of matched nodes
composeTestRule
    .onAllNodesWithContentDescription("Beatle").assertCountEquals(4)
// At least one matches
composeTestRule
    .onAllNodesWithContentDescription("Beatle").assertAny(hasTestTag("Drummer"))
// All of them match
composeTestRule
    .onAllNodesWithContentDescription("Beatle").assertAll(hasClickAction())

Acciones

Para insertar una acción en un nodo, llama a una función perform…():

composeTestRule.onNode(...).performClick()

Estos son algunos ejemplos de acciones:

performClick(),
performSemanticsAction(key),
performKeyPress(keyEvent),
performGesture { swipeLeft() }

Puedes consultar la lista completa en la hoja de referencia para pruebas de Compose.

Comparadores

En esta sección se describen algunos de los comparadores disponibles para probar tu código de Compose.

Comparadores jerárquicos

Los comparadores jerárquicos te permiten subir o bajar por el árbol semántico, y realizar vinculaciones simples.

fun hasParent(matcher: SemanticsMatcher): SemanticsMatcher
fun hasAnySibling(matcher: SemanticsMatcher): SemanticsMatcher
fun hasAnyAncestor(matcher: SemanticsMatcher): SemanticsMatcher
fun hasAnyDescendant(matcher: SemanticsMatcher):  SemanticsMatcher

Estos son algunos ejemplos de cómo se usan esos comparadores:

composeTestRule.onNode(hasParent(hasText("Button")))
    .assertIsDisplayed()

Selectores

Otra forma de crear pruebas es usar selectores, que pueden hacer que algunas pruebas sean más legibles.

composeTestRule.onNode(hasTestTag("Players"))
    .onChildren()
    .filter(hasClickAction())
    .assertCountEquals(4)
    .onFirst()
    .assert(hasText("John"))

Puedes consultar la lista completa en la hoja de referencia para pruebas de Compose.

Sincronización

Las pruebas de Compose se sincronizan de forma predeterminada con tu IU. Cuando llames a una aserción o una acción a través de ComposeTestRule, la prueba se sincronizará antes y se esperará hasta que el árbol de IU esté inactivo.

Normalmente, no es necesario que realices ninguna acción. Sin embargo, hay algunos casos extremos que debes conocer.

Cuando se sincroniza una prueba, tu app de Compose está avanzada a tiempo con un reloj virtual. Eso significa que las pruebas de Compose no se ejecutan en tiempo real, por lo que pueden pasar tan rápido como sea posible.

Sin embargo, si no usas los métodos que sincronizan tus pruebas, no se producirá una recomposición, y la IU se pausará.

@Test
fun counterTest() {
    val myCounter = mutableStateOf(0) // State that can cause recompositions
    var lastSeenValue = 0 // Used to track recompositions
    composeTestRule.setContent {
        Text(myCounter.value.toString())
        lastSeenValue = myCounter.value
    }
    myCounter.value = 1 // The state changes, but there is no recomposition

    // Fails because nothing triggered a recomposition
    assertTrue(lastSeenValue == 1)

    // Passes because the assertion triggers recomposition
    composeTestRule.onNodeWithText("1").assertExists()
}

También es importante tener en cuenta que este requisito solo se aplica a las jerarquías de Compose y no al resto de la app.

Cómo inhabilitar la sincronización automática

Cuando llamas a una aserción o una acción a través de ComposeTestRule, como assertExists(), tu prueba se sincroniza con la IU de Compose. En algunos casos, puedes detener esta sincronización y controlar tú mismo el reloj. Por ejemplo, puedes controlar el tiempo para tomar capturas de pantalla precisas de una animación en un punto en el que la IU esté ocupada. Para inhabilitar la sincronización automática, configura la propiedad autoAdvance de mainClock como false:

composeTestRule.mainClock.autoAdvance = false

Normalmente, avanzarás el tiempo tú mismo. Puedes avanzar de a un fotograma con advanceTimeByFrame() o por una duración específica con advanceTimeBy():

composeTestRule.mainClock.advanceTimeByFrame()
composeTestRule.mainClock.advanceTimeBy(milliseconds)

Recursos inactivos

Compose puede sincronizar pruebas y la IU para que todas las acciones y aserciones se realicen en estado inactivo, a la espera del reloj o avanzándolo según sea necesario. Sin embargo, algunas operaciones asíncronas cuyos resultados afectan el estado de la IU se pueden ejecutar en segundo plano mientras la prueba no las tiene en cuenta.

Puedes crear y registrar estos recursos inactivos en tu prueba para que se los tenga en cuenta cuando determines si la app en cuestión está ocupada o inactiva. No tienes que realizar ninguna acción, a menos que necesites registrar recursos inactivos adicionales, por ejemplo, si ejecutas un trabajo en segundo plano que no se sincroniza con Espresso ni Compose.

Esta API es muy similar a los recursos inactivos de Espresso para indicar si el sujeto en cuestión está inactivo o ocupado. Usa la regla de prueba de Compose para registrar la implementación del IdlingResource.

composeTestRule.registerIdlingResource(idlingResource)
composeTestRule.unregisterIdlingResource(idlingResource)

Sincronización manual

En algunos casos, debes sincronizar la IU de Compose con otras partes de la prueba o la app que estás probando.

waitForIdle espera a que Compose esté inactivo, pero depende de la propiedad autoAdvance:

composeTestRule.mainClock.autoAdvance = true // default
composeTestRule.waitForIdle() // Advances the clock until Compose is idle

composeTestRule.mainClock.autoAdvance = false
composeTestRule.waitForIdle() // Only waits for Idling Resources to become idle

Ten en cuenta que, en ambos casos, waitForIdle también esperará a que se hayan pasado el diseño y dibujo pendientes.

Además, puedes adelantar el reloj hasta que se cumpla una condición determinada con advanceTimeUntil().

composeTestRule.mainClock.advanceTimeUntil(timeoutMs) { condition }

Ten en cuenta que la condición determinada debe comprobar el estado que puede verse afectado por este reloj (solo funciona con el estado de Compose). Cualquier condición que dependa de la medición o el dibujo de Android (es decir, medición o dibujo externo a Compose) debe usar un concepto más general, como waitUntil():

composeTestRule.waitUntil(timeoutMs) { condition }

Patrones comunes

En esta sección, se describen algunos enfoques comunes que verás en las pruebas de Compose.

Prueba aislada

ComposeTestRule te permite iniciar una actividad que muestre elementos componibles: tu aplicación completa, una sola pantalla o un elemento pequeño. También se recomienda comprobar que los elementos que admiten composición estén bien encapsulados y funcionen de manera independiente para poder probar las IU de forma más fácil y precisa.

Eso no significa que solo deberías crear pruebas de IU por unidad. También son muy importantes las pruebas de IU que abarcan partes más grandes de ella.

Cómo acceder a la actividad y a los recursos después de configurar tu propio contenido

A menudo, debes configurar el contenido que se prueba con composeTestRule.setContent y también debes acceder a los recursos de las actividades, por ejemplo, para afirmar que un texto que se muestra coincide con un recurso de strings. Sin embargo, no puedes llamar a setContent en una regla creada con createAndroidComposeRule() si la actividad ya la llama.

Un patrón común para lograr esto es crear un AndroidComposeTestRule mediante una actividad vacía (como ComponentActivity).

class MyComposeTest {

    @get:Rule
    val composeTestRule = createAndroidComposeRule<ComponentActivity>()

    @Test
    fun myTest() {
        // Start the app
        composeTestRule.setContent {
            MyAppTheme {
                MainScreen(uiState = exampleUiState, /*...*/)
            }
        }
        val continueLabel = composeTestRule.activity.getString(R.string.next)
        composeTestRule.onNodeWithText(continueLabel).performClick()
    }
}

Ten en cuenta que ComponentActivity debe agregarse al archivo AndroidManifest.xml de tu app. Puedes hacerlo agregando esta dependencia a tu módulo:

debugImplementation("androidx.compose.ui:ui-test-manifest:$compose_version")

Propiedades semánticas personalizadas

Puedes crear propiedades semánticas personalizadas para exponer información a las pruebas. Para ello, define una SemanticsPropertyKey nueva y haz que esté disponible mediante SemanticsPropertyReceiver.

// Creates a Semantics property of type boolean
val PickedDateKey = SemanticsPropertyKey<Long>("PickedDate")
var SemanticsPropertyReceiver.pickedDate by PickedDateKey

Ahora puedes usar esa propiedad con el modificador semantics:

val datePickerValue by remember { mutableStateOf(0L) }
MyCustomDatePicker(
    modifier = Modifier.semantics { pickedDate = datePickerValue }
)

A partir de las pruebas, puedes usar SemanticsMatcher.expectValue para afirmar el valor de la propiedad:

composeTestRule
    .onNode(SemanticsMatcher.expectValue(PickedDateKey, 1445378400)) // 2015-10-21
    .assertExists()

Cómo verificar el restablecimiento del estado

Debes verificar que el estado de tus elementos de Compose se restablezca correctamente cuando se vuelva a crear la actividad o el proceso. Es posible realizar esta verificación sin depender de la recreación de la actividad con la clase StateRestorationTester.

Esta clase te permite simular la recreación de un elemento que admite composición. Es especialmente útil para verificar la implementación de rememberSaveable.


class MyStateRestorationTests {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun onRecreation_stateIsRestored() {
        val restorationTester = StateRestorationTester(composeTestRule)

        restorationTester.setContent { MainScreen() }

        // TODO: Run actions that modify the state

        // Trigger a recreation
        restorationTester.emulateSavedInstanceStateRestore()

        // TODO: Verify that state has been correctly restored.
    }
}

Depuración

La principal manera de resolver problemas en tus pruebas es observar el árbol semántico. Para imprimir el árbol, llama a composeTestRule.onRoot().printToLog() en cualquier punto de la prueba. Esta función imprime un registro como este:

Node #1 at (...)px
 |-Node #2 at (...)px
   OnClick = '...'
   MergeDescendants = 'true'
    |-Node #3 at (...)px
    | Text = 'Hi'
    |-Node #5 at (83.0, 86.0, 191.0, 135.0)px
      Text = 'There'

Esos registros contienen información valiosa para el seguimiento de errores.

Interoperabilidad con Espresso

En una app híbrida, puedes encontrar componentes de Compose dentro de jerarquías de vistas y vistas en elementos componibles de Compose (a través del elemento componible AndroidView).

No es necesario realizar pasos especiales para coincidir con alguno de los tipos. Haz coincidir las vistas a través de elementos onView de Espresso y de elementos de Compose mediante ComposeTestRule.

@Test
fun androidViewInteropTest() {
    // Check the initial state of a TextView that depends on a Compose state:
    Espresso.onView(withText("Hello Views")).check(matches(isDisplayed()))
    // Click on the Compose button that changes the state
    composeTestRule.onNodeWithText("Click here").performClick()
    // Check the new value
    Espresso.onView(withText("Hello Compose")).check(matches(isDisplayed()))
}

Interoperabilidad con UiAutomator

De forma predeterminada, solo se puede acceder a los elementos componibles desde UiAutomator mediante sus descriptores convenientes (texto mostrado, descripción del contenido, etcétera). Si quieres acceder a cualquier elemento componible que usa Modifier.testTag, debes habilitar la propiedad semántica testTagAsResourceId para el subárbol específico de elementos componibles. Habilitar este comportamiento es útil para elementos componibles que no tienen ningún otro controlador único, como los que se pueden desplazar (por ejemplo, LazyColumn).

Solo puedes habilitarla una vez que esté alta en la jerarquía de elementos componibles para asegurarte de que se pueda acceder a todos estos elementos anidados con Modifier.testTag desde UiAutomator.

Scaffold(
    // Enables for all composables in the hierarchy.
    modifier = Modifier.semantics {
        testTagsAsResourceId = true
    }
){
    // Modifier.testTag is accessible from UiAutomator for composables nested here.
    LazyColumn(
        modifier = Modifier.testTag("myLazyColumn")
    ){
        // content
    }
}

Se puede acceder a cualquier elemento componible con Modifier.testTag(tag) mediante el uso de By.res(resourceName) con el mismo tag que resourceName.

val device = UiDevice.getInstance(getInstrumentation())

val lazyColumn: UiObject2 = device.findObject(By.res("myLazyColumn"))
// some interaction with the lazyColumn

Más información

Para obtener más información, prueba el codelab de prueba de Jetpack Compose.

Ejemplos