Saltar al contenido
K
CalidadPaso a paso25 min Avanzado

Pruebas de interfaz en Android

Al terminar: Probar la lista con datos de mentira, sin emulador.

Los tests que tienes prueban el núcleo. Ninguno comprueba que una lista se pinte, que una tarjeta avise de cuál se pulsó o que una pantalla vacía diga algo. Al terminar esta lección tendrás tres tests de interfaz de la lista, que arrancan en unos segundos, sin emulador, desde la Terminal de Android Studio.

Y el tercero que escribas va a encontrar un fallo de verdad, que lleva en la lista desde que la hiciste.

En la lección siguiente, Probar la pantalla de entrada en Android, harás lo mismo con la pantalla de sesión y llegarás a trece tests.

Todos los comandos se escriben en la Terminal de Android Studio, en la carpeta de tu proyecto.

El concepto · Un test que mira la pantalla

Una prueba de Compose se escribe con una regla de test (createComposeRule): un objeto que monta una pantalla suelta, te deja buscar elementos en ella, pulsarlos y comprobar lo que se ve. Un test de interfaz de Android puede correr de dos maneras. En un dispositivo o emulador: es fiel, pero lento y necesita un Android arrancado. O dentro de la JVM —la máquina virtual de Java que ya ejecuta tus otros tests— con Robolectric, una librería que simula Android en el mismo proceso.

Este reparto decide qué se prueba aquí:

  • Aquí, en la JVM: lógica de pantalla. Qué botón se habilita, qué mensaje se pinta, qué aviso se dispara al pulsar, qué se ve con una lista vacía. Son muchos casos y cada uno tarda décimas de segundo.
  • En un dispositivo o simulador real: lo que necesita el sistema de verdad (gestos, navegación entre pestañas, que algo sobreviva a matar la app). Son pocos casos y más lentos. En iOS lo harás en la lección Pruebas de interfaz en iOS.

Un test de Compose no ve píxeles: consulta el árbol de semántica, una descripción de la pantalla (textos, botones, si están habilitados) que también usan los lectores de pantalla como TalkBack. Es un árbol porque unas cosas contienen a otras: la pantalla contiene una columna, y la columna contiene un texto y un botón. Cada elemento de ese árbol es un nodo, y por eso los buscadores del test se llaman «buscar un nodo».

(Una Activity es la pantalla de sistema de Android que aloja tu interfaz; ya la usas en MainActivity. Nosotros usaremos la regla sencilla, createComposeRule.)

Un test de interfaz, línea a línea

Antes de tocar la Pokédex, un ejemplo ajeno al proyecto: una pantalla de tienda con un texto y un botón que suma un producto al carrito. El test hace lo que hacen todos: prepara (monta la pantalla), actúa (pulsa) y comprueba (mira lo que se ve).

ejemplo mínimo: un test de interfaz de una tienda (con comentarios)
// La pantalla: un texto con lo que llevas y un botón que suma uno.
@Composable
fun Carrito() {
    // Un número que Compose recuerda y, cuando cambia, vuelve a pintar el texto.
    var productos by remember { mutableIntStateOf(0) }
    Column {
        Text("$productos productos")
        Button(onClick = { productos++ }) { Text("Añadir al carrito") }
    }
}
 
// Ejecuta los tests con Robolectric: el Android simulado dentro de la JVM.
// `RobolectricTestRunner::class` es «la clase RobolectricTestRunner» pasada
// como valor: así le dices a JUnit cuál es el motor.
@RunWith(RobolectricTestRunner::class)
class CarritoTest {
 
    // La regla monta la pantalla y da los buscadores. La marca `@get:Rule`
    // (con `get:`, por una rareza de Kotlin; cópiala tal cual) hace que JUnit
    // la cree antes de cada test y la limpie después.
    @get:Rule
    val compose = createComposeRule()
 
    @Test
    fun `pulsar el boton suma un producto`() {
        compose.setContent { Carrito() }                            // prepara: monta la pantalla
        compose.onNodeWithText("Añadir al carrito").performClick()  // actúa: busca el botón y púlsalo
        compose.onNodeWithText("1 productos").assertIsDisplayed()   // comprueba lo que se ve
    }
}

El nombre de la función va entre comillas invertidas, con espacios: Kotlin lo permite, y así el informe de resultados se lee como una frase. Es lo que ya hiciste en las pruebas del formulario de El formulario de entrada en Android.

El test pasa. Y si buscas un texto que no está, por ejemplo "2 productos", falla así, y el mensaje dice qué buscaba:

Assert failed: The component with Text + InputText + EditableText contains '2 productos' (ignoreCase: false) is not displayed!

Eso es todo el mecanismo. Lo que sigue es lo mismo con la lista de la Pokédex.

Paso 1 · Instala las librerías de test

Objetivo: que androidApp pueda compilar y ejecutar tests de Compose en la JVM. Es la receta de Los ficheros de Gradle de tu proyecto con una novedad: hasta ahora solo shared tenía tests, y esta es la primera vez que los tiene la app.

gradle
libs.versions.tomlse modifica: [versions] y [libraries]
androidApp
build.gradle.ktsse modifica: testOptions y dependencies
Los dos ficheros de la instalación, con las rutas de la vista Project.

1 · Qué instalas. Cuatro librerías. Las tres primeras solo existen al ejecutar tests; la cuarta, solo en la app de depuración. En la app que se publica no viaja ninguna:

  • junit (JUnit 4): el motor que ejecuta los tests de Android. Aporta @Test y las reglas (@Rule).
  • robolectric: el Android simulado de arriba.
  • ui-test-junit4: lo propio de Compose: la regla createComposeRule y los buscadores (onNodeWithText…). Sin versión: la pone el BOM de Compose (bill of materials, «lista de materiales»: una dependencia especial que fija a la vez la versión de todas las librerías de Compose para que casen entre sí; ya la usas en implementation(platform(libs.androidx.compose.bom))).
  • ui-test-manifest: añade a la app de depuración una Activity vacía donde la regla monta la pantalla que quieres probar. La app que se publica no la lleva.

Y kotlin("test"), el atajo que ya usaste en shared para assertEquals.

2 · Las versiones, en [versions]. Abre gradle/libs.versions.toml. Antes de añadir nada, busca la palabra junit en el fichero (Cmd + F): el asistente de Android Studio a veces trae ya junit en el catálogo. Dos casos:

  • No sale: añade las dos líneas de abajo tal cual.
  • Ya sale: no lo repitas. Añade solo la línea robolectric, salta la línea junit de este paso y la de [libraries], y usa el nombre que ya tengas en el paso 5, donde pone libs.junit (si tu línea se llama junit, no cambia nada).

Añade al final de [versions] (la cabecera ya existe):

gradle/libs.versions.toml: [versions], líneas nuevas
robolectric = "4.17"
junit = "4.13.2"

3 · Las librerías, en [libraries]. Al final de esa sección, en un bloque aparte:

gradle/libs.versions.toml: [libraries], líneas nuevas
junit = { module = "junit:junit", version.ref = "junit" }
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
# Sin version: la fija el BOM de Compose.
androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }

4 · No son plugins: [plugins] y el build.gradle.kts de la raíz no se tocan.

5 · Dos cosas en el módulo. Abre androidApp/build.gradle.kts. Primero, dentro del bloque android { … }, justo debajo de buildFeatures { … }, un ajuste:

androidApp/build.gradle.kts: dentro de android
    testOptions {
        unitTests {
            // Robolectric necesita los recursos y el manifiesto ya compilados
            // de la app: sin esta línea no encuentra la Activity de prueba.
            isIncludeAndroidResources = true
        }
    }

Y al final de dependencies { … }, debajo de la última implementation:

androidApp/build.gradle.kts: dependencias nuevas
    // testImplementation: solo existe al compilar y ejecutar los tests; la app
    // que instalas no las lleva (como en Marcador).
    testImplementation(libs.junit)
    testImplementation(libs.robolectric)
    testImplementation(kotlin("test"))
    testImplementation(libs.androidx.compose.ui.test.junit4)
    // debugImplementation: va dentro de la app de depuración, no solo de los
    // tests, porque es un manifiesto con una Activity vacía. Es el mismo tipo
    // de dependencia que LeakCanary en Marcador.
    debugImplementation(libs.androidx.compose.ui.test.manifest)

6 · Sync Now, y comprueba. Pulsa Sync Now. Si sale en rojo, repasa la caja «Si Sync sale en rojo» de Los ficheros de Gradle de tu proyecto. Los fallos de esta receta, tal como los da Gradle:

  • Falta la línea de [libraries]: Unresolved reference 'robolectric' en el build.gradle.kts.
  • Falta la de [versions]: Undefined version reference … version reference 'robolectric' doesn't exist.
  • Una línea repetida en el catálogo (por ejemplo, junit dos veces): TOML syntax error … previously defined at line ….

Para comprobar, pide los tests de la app (todavía no hay ninguno):

./gradlew :androidApp:testDebugUnitTest

Debe terminar en BUILD SUCCESSFUL, con una línea > Task :androidApp:testDebugUnitTest NO-SOURCE: la tarea funciona y no encuentra tests.

Paso 2 · El primer test: la lista

El archivo. Un test de interfaz monta una pantalla con datos inventados y comprueba lo que se ve. Para montar la lista hay que poder construirla sin Koin, Room ni Ktor. Ya está preparada: PokemonListScreen pide los ViewModel y se los pasa a PokemonListContent, que solo pinta. Esa partida en dos es el patrón: el que pide, y el que pinta con lo que le dan.

Solo falta que el test pueda verla. Ábrela, es androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/list/PokemonListScreen.kt, y cambia private por internal en la cabecera de PokemonListContent:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/list/PokemonListScreen.kt: la cabecera de PokemonListContent
// `internal`: visible dentro del módulo `androidApp` (los tests incluidos),
// invisible fuera. `private` la escondía hasta del test.
internal fun PokemonListContent(

Créalo. Clic derecho sobre la carpeta androidApp → New → File y esta ruta (crea las carpetas que falten):

src/test/kotlin/com/sebastiannarvaez/pokedex/android/list/PokemonListContentTest.kt

Escríbelo por partes. Primero, los datos de prueba, que sustituyen a PokeAPI y a la base de datos:

androidApp/src/test/kotlin/com/sebastiannarvaez/pokedex/android/list/PokemonListContentTest.kt: los datos, antes de la clase
package com.sebastiannarvaez.pokedex.android.list
 
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.paging.LoadState
import androidx.paging.LoadStates
import androidx.paging.PagingData
import androidx.paging.compose.collectAsLazyPagingItems
import com.sebastiannarvaez.pokedex.domain.Pokemon
import com.sebastiannarvaez.pokedex.domain.PokemonType
import com.sebastiannarvaez.pokedex.feature.search.SearchUiState
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
 
// Un Pokémon inventado: solo lo que la tarjeta necesita para pintarse.
private fun pokemon(id: Int, nombre: String) = Pokemon(
    id = id,
    name = nombre,
    types = listOf(PokemonType.GRASS),
)
 
// El estado de carga «ya terminó». Son tres estados «ya cargó, no hay más»
// (la carga inicial y los dos sentidos de seguir cargando): cópialos tal cual.
// Sin ellos, Paging cree que sigue cargando y la pantalla enseña la ruleta.
private val TERMINADO = LoadStates(
    refresh = LoadState.NotLoading(false),
    prepend = LoadState.NotLoading(true),
    append = LoadState.NotLoading(true),
)

Después, la clase. Cada línea con anotación tiene su motivo:

androidApp/src/test/kotlin/com/sebastiannarvaez/pokedex/android/list/PokemonListContentTest.kt: la clase y su ayudante
// Dice a JUnit que ejecute los tests con Robolectric, o sea, con el Android
// simulado de arriba.
@RunWith(RobolectricTestRunner::class)
class PokemonListContentTest {
 
    // La regla de Compose: monta la pantalla y da los buscadores. Con
    // `@get:Rule`, JUnit la crea y la limpia para cada test.
    @get:Rule
    val compose = createComposeRule()
 
    // Se llama `Pintar` con mayúscula porque es un composable (la convención de
    // Compose). Pinta la pantalla de verdad (PokemonListContent) con datos
    // fijos. Un `PagingData` suelto no se puede leer: hay que pasarlo por un
    // Flow y por `collectAsLazyPagingItems`, lo mismo que hace PokemonListScreen.
    @Composable
    private fun Pintar(datos: PagingData<Pokemon>) {
        val flujo = remember { MutableStateFlow(datos) }
        PokemonListContent(
            pokemon = flujo.collectAsLazyPagingItems(),
            busqueda = SearchUiState(),
            expandida = false,
            alCambiarExpansion = {},
            alEscribir = {},
            alLimpiar = {},
            alPulsar = {},
            idsFavoritos = emptySet(),
            alMarcar = {},
        )
    }

Y el test, con las tres partes de cualquier test: preparar (montar la pantalla), actuar (pulsar o teclear, si toca) y comprobar (mirar lo que se ve).

androidApp/src/test/kotlin/com/sebastiannarvaez/pokedex/android/list/PokemonListContentTest.kt: el primer test (dentro de la clase, y cierra la clase con })
    // El nombre entre comillas invertidas se lee como una frase en el informe.
    @Test
    fun `la lista ensena el nombre que recibe`() {
        // Prepara: monta la pantalla con un solo Pokémon.
        compose.setContent {
            Pintar(PagingData.from(listOf(pokemon(1, "bulbasaur")), TERMINADO))
        }
 
        // Comprueba: busca en pantalla un nodo con ese texto. La tarjeta
        // escribe el nombre con inicial mayúscula (PokeAPI lo da en minúsculas),
        // y el test busca lo que se **ve**, no lo que se guarda.
        compose.onNodeWithText("Bulbasaur").assertIsDisplayed()
    }
}

Ejecútalo en la Terminal:

./gradlew :androidApp:testDebugUnitTest

Primer rojo: Java 21

Si los tests se ejecutan con el JDK 17 de JDK y verificación del entorno (lo normal en esta guía), fallan, y ni empiezan. En la Terminal solo se ve:

PokemonListContentTest > classMethod FAILED
    java.lang.UnsupportedOperationException at DefaultSdkProvider.java:174

El mensaje entero está en el informe de resultados. Ábrelo desde la Terminal (open es la orden de macOS que abre un fichero con su programa, aquí el navegador):

open androidApp/build/reports/tests/testDebugUnitTest/index.html

Haz clic en la clase que falla y busca:

java.lang.UnsupportedOperationException: Failed to create a Robolectric sandbox:
Android SDK 37 requires Java 21 (have Java 17)

Este informe es el sitio donde mirar de ahora en adelante: con todo en verde, Gradle solo dice BUILD SUCCESSFUL, y las cifras de los tests están ahí.

Sin más indicaciones, Robolectric simula el SDK de la app (el 37), y para las versiones 36 y superiores exige Java 21. Se le dice con qué SDK ejecutarse en un fichero de propiedades propio. Créalo igual que el anterior:

src/test/resources/robolectric.properties
androidApp/src/test/resources/robolectric.properties
# El SDK de Android que simula Robolectric. El 35 funciona con Java 17 y con
# cualquier JDK posterior, así que no depende de con cuál corra tu Gradle.
# El precio: estos tests no ven el comportamiento nuevo de Android 36 ni 37.
sdk=35

Si en tu máquina no salió el error (tu Gradle ya corre con Java 21 o más), escribe el fichero igual: así los tests no dependen del JDK de cada uno. Vuelve a ejecutar: debe terminar en BUILD SUCCESSFUL.

Segundo rojo: Koin ya arrancado

Un test no basta para ver el siguiente problema. Añade otro: pulsar una tarjeta debe avisar de qué Pokémon se pulsó. Primero, el ayudante Pintar recibe el aviso (cambia su cabecera y la línea alPulsar):

PokemonListContentTest.kt: el aviso de pulsación (dos líneas cambian)
    @Composable
    private fun Pintar(datos: PagingData<Pokemon>, alPulsar: (Int) -> Unit = {}) {
        // … el resto igual …
            alPulsar = alPulsar,

Añade a los import androidx.compose.ui.test.performClick y kotlin.test.assertEquals, y este test dentro de la clase:

androidApp/src/test/kotlin/com/sebastiannarvaez/pokedex/android/list/PokemonListContentTest.kt: el segundo test
    @Test
    fun `pulsar una tarjeta devuelve su identificador`() {
        var pulsado = -1
        compose.setContent {
            // El segundo parámetro es el aviso: apunta el número que llega.
            Pintar(PagingData.from(listOf(pokemon(25, "pikachu")), TERMINADO), alPulsar = { pulsado = it })
        }
 
        // Pulsa el nodo que dice «Pikachu» y comprueba qué número llegó.
        compose.onNodeWithText("Pikachu").performClick()
 
        assertEquals(25, pulsado)
    }

Ejecuta. Uno de los dos pasa y el otro falla. Cuál de los dos, depende del orden: JUnit no garantiza en qué orden corre los tests de una clase, y el que va segundo es el que se encuentra a Koin ya arrancado. En la Terminal se ve KoinApplicationAlreadyStartedException at GlobalContext.kt:44; el mensaje entero, en el informe:

org.koin.core.error.KoinApplicationAlreadyStartedException: A Koin Application has already been started

Robolectric arranca la Application de verdad para cada test, y la de la Pokédex arranca Koin: el segundo test lo encuentra ya arrancado. La salida es darle otra Application que no haga nada. Los tests de una pantalla con datos fijos no necesitan el grafo. Crea el fichero:

src/test/kotlin/com/sebastiannarvaez/pokedex/android/TestApp.kt
androidApp/src/test/kotlin/com/sebastiannarvaez/pokedex/android/TestApp.kt
package com.sebastiannarvaez.pokedex.android
 
import android.app.Application
 
// Una Application vacía: es la que arrancan los tests en lugar de
// PokedexApplication, que arranca Koin.
class TestApp : Application()

Y díselo a Robolectric: añade una línea a robolectric.properties.

androidApp/src/test/resources/robolectric.properties: línea nueva
# Sin esta línea, el segundo test falla con KoinApplicationAlreadyStartedException.
application=com.sebastiannarvaez.pokedex.android.TestApp

Ejecuta de nuevo: los dos pasan.

Paso 3 · El fallo que encuentra un test

Un test que nadie ha escrito no encuentra nada. Escribe este, con la idea de que pase: una lista que termina de cargar y viene vacía debe explicarlo.

androidApp/src/test/kotlin/com/sebastiannarvaez/pokedex/android/list/PokemonListContentTest.kt: el tercer test
    @Test
    fun `sin resultados se explica, no se deja una pantalla en blanco`() {
        // Carga terminada y cero Pokémon.
        compose.setContent { Pintar(PagingData.from(emptyList(), TERMINADO)) }
 
        // `substring = true`: basta con que el texto contenga la frase.
        compose.onNodeWithText("No hay nada que enseñar", substring = true).assertIsDisplayed()
    }

Ejecuta. Falla, y el mensaje es el de la prueba, no el de una excepción rara:

Assert failed: The component with Text + InputText + EditableText contains 'No hay nada que enseñar' (ignoreCase: false) as substring is not displayed!

La pantalla, con la carga terminada y cero elementos, caía en la rama de la rejilla y pintaba una rejilla vacía: una pantalla en blanco, igual que un fallo silencioso. En PokemonListScreen.kt, dentro del when de PokemonListContent, busca la última rama, la que empieza por else -> Rejilla(. No la toques (la tuya lleva más parámetros que cualquier ejemplo; por ejemplo, estado = estadoRejilla, el que recuerda por dónde has desplazado la rejilla, de La lista sin red en Android): añade la rama nueva justo encima, en una línea en blanco propia:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/list/PokemonListScreen.kt: la rama nueva, justo encima de la rama else
                // Carga terminada y cero elementos: sin esta rama, caería en
                // la rejilla y dejaría la pantalla en blanco.
                pokemon.loadState.refresh is LoadState.NotLoading && pokemon.itemCount == 0 ->
                    Centrado {
                        EstadoDeError(
                            titulo = "No hay nada que enseñar",
                            detalle = "PokeAPI no devolvió ningún Pokémon.",
                            reintentar = { pokemon.refresh() },
                        )
                    }

El orden importa porque un when toma la primera rama que se cumple: la nueva tiene que ir antes que else, que se cumple siempre. (Centrado y EstadoDeError ya existen desde La lista en Android.) Ejecuta: pasa.

Ya tienes tres tests de la lista. En la lección siguiente harás lo mismo con la pantalla de entrada, que primero hay que partir en dos para poder probarla.

Punto de control

  • ./gradlew :androidApp:testDebugUnitTest termina en BUILD SUCCESSFUL y el informe (androidApp/build/reports/tests/testDebugUnitTest/index.html) cuenta 3 tests, todos de PokemonListContentTest, con 0 fallos.
  • Los tests del núcleo no cambian: las tres tareas de siempre (:shared:jvmTest, :shared:testAndroidHostTest y :shared:iosSimulatorArm64Test, sin ningún --tests que las recorte) siguen en 127, 89 y 92.
  • robolectric.properties fija sdk=35 y apunta a TestApp.
  • Una lista que termina de cargar sin elementos enseña «No hay nada que enseñar», no una rejilla en blanco.
  • La app sigue arrancando en el emulador como antes: la que se publica no lleva las librerías de test, y la de depuración solo lleva la Activity vacía.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.¿Por qué los tests de interfaz de Android corren en la JVM y no en un emulador?

  2. 2.El segundo test falla con KoinApplicationAlreadyStartedException. ¿Por qué?

  3. 3.¿Por qué el fichero de Robolectric fija sdk=35 y no la versión de Android contra la que compila la app?

  4. 4.¿Qué fallo real destapó el test de la lista vacía?

  5. 5.¿Por qué se cambia private por internal en la cabecera de PokemonListContent?

Faltan 5 de 5