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).
// 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.
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@Testy las reglas (@Rule).robolectric: el Android simulado de arriba.ui-test-junit4: lo propio de Compose: la reglacreateComposeRuley 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 enimplementation(platform(libs.androidx.compose.bom))).ui-test-manifest: añade a la app de depuración unaActivityvací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íneajunitde este paso y la de[libraries], y usa el nombre que ya tengas en el paso 5, donde ponelibs.junit(si tu línea se llamajunit, no cambia nada).
Añade al final de [versions] (la cabecera ya existe):
robolectric = "4.17"
junit = "4.13.2"3 · Las librerías, en [libraries]. Al final de esa sección, en un bloque aparte:
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:
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:
// 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 elbuild.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,
junitdos veces):TOML syntax error … previously defined at line ….
Para comprobar, pide los tests de la app (todavía no hay ninguno):
./gradlew :androidApp:testDebugUnitTestDebe 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:
// `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.ktEscríbelo por partes. Primero, los datos de prueba, que sustituyen a PokeAPI y a la base de datos:
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:
// 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).
// 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:testDebugUnitTestPrimer 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:174El 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.htmlHaz 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# 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=35Si 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):
@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:
@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 startedRobolectric 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.ktpackage 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.
# Sin esta línea, el segundo test falla con KoinApplicationAlreadyStartedException.
application=com.sebastiannarvaez.pokedex.android.TestAppEjecuta 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.
@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:
// 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:testDebugUnitTesttermina enBUILD SUCCESSFULy el informe (androidApp/build/reports/tests/testDebugUnitTest/index.html) cuenta 3 tests, todos dePokemonListContentTest, con 0 fallos.- Los tests del núcleo no cambian: las tres tareas de siempre (
:shared:jvmTest,:shared:testAndroidHostTesty:shared:iosSimulatorArm64Test, sin ningún--testsque las recorte) siguen en 127, 89 y 92. robolectric.propertiesfijasdk=35y apunta aTestApp.- 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
Activityvacía.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.¿Por qué los tests de interfaz de Android corren en la JVM y no en un emulador?
2.El segundo test falla con
KoinApplicationAlreadyStartedException. ¿Por qué?3.¿Por qué el fichero de Robolectric fija
sdk=35y no la versión de Android contra la que compila la app?4.¿Qué fallo real destapó el test de la lista vacía?
5.¿Por qué se cambia
privateporinternalen la cabecera dePokemonListContent?