Cómo probar tu diseño de Compose

Las pruebas de una IU basada en Compose son distintas a las de una IU basada en vistas. El kit de herramientas de la IU basada en vistas define claramente qué es una vista. Las vistas ocupan un espacio rectangular, pueden ser widgets o diseños, y tienen propiedades, como identificadores, posición, margen, padding, etcétera.

Compose utiliza otro enfoque. En lugar de elementos View, se definen funciones que admiten composición y que emiten la IU. Los elementos que admiten composición no tienen un ID ni descripción de contenido. Entonces, ¿cómo se hace, por ejemplo, para hacer clic en un botón en una prueba de IU? En este documento se explican los enfoques equivalentes para Compose.

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 "Me gusta" ("Like"). 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 { accessibilityLabel = "Like button" })

Descubre cómo se usan las propiedades de Semantics para mejorar la accesibilidad de tu app.

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 createComposeRule, but not createAndroidComposeRule:
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 = createAndroidComposeRule<MyActivity>()
    // createComposeRule() if you don't need access to the activityTestRule

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

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

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

API de prueba

Si usaste Espresso, las API de prueba de Compose te resultarán familiares.

Compose utiliza comparadores SemanticsMatcher para hacer referencia a uno o más nodos en el árbol semántico. Una vez que vincules uno o más nodos, podrás realizar acciones sobre ellos o hacer aserciones.

Buscadores

Para 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
   OnClick = '...'
   Text = Hello World
   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 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() }

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"))

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.

Cómo realizar pruebas de forma aislada

ComposeTestRule te permite iniciar una actividad que muestre elementos que admiten composición: tu aplicación completa, una sola pantalla o un widget. 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 simular cambios de configuración

La configuración en Compose se realiza mediante ambientes. Los ambientes describen las características del dispositivo en el que se ejecuta tu app. Cada vez que cambia la configuración, el ambiente recompone la parte pertinente del árbol. La configuración contiene información como el tamaño y la orientación de la pantalla, el modo nocturno, etc. En lugar de pedir al dispositivo que cambie esos parámetros, es mucho más rápido y confiable simular el cambio de configuración en Compose:

class MyTest() {

    private val themeIsDark = MutableStateFlow(false)

    @Before
    fun setUp() {
        composeTestRule.setContent {
            JetchatTheme(
                isDarkTheme = themeIsDark.collectAsState(false).value
            ) {
                MainScreen()
            }
        }
    }

    @Test
    fun changeTheme_scrollIsPersisted() {
        composeTestRule.onNodeWithContentDescription("Continue").performClick()

        // Set theme to dark
        themeIsDark.value = true

        // Check that we're still on the same page
        composeTestRule.onNodeWithContentDescription("Welcome").assertIsDisplayed()
    }
}

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

Depuración

La principal manera de resolver problemas en tus pruebas es observar el árbol semántico. Para mostrar el árbol, llama a findRoot().printToLog() en cualquier punto de la prueba. Esta función muestra 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()))
}

Más información

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