Probar la pantalla de entrada en Android
Al terminar: Probar la pantalla de sesión separando lo que se ve de lo que se decide.
La lista ya tiene tres tests de interfaz. Falta la otra pantalla que importa: la de entrada, donde un botón que se habilita mal impide a alguien usar la app. Al terminar esta lección tendrás trece tests de interfaz: los nueve de la pantalla de entrada y los cuatro de la lista (los tres que ya tienes y uno nuevo), que corren juntos en unos segundos y sin emulador.
Para probar la pantalla de entrada hay que hacer antes una cosa que la lista ya traía hecha: partirla en dos. Una parte pide el ViewModel y la otra solo pinta lo que le dan. Sin esa partición, cada test necesitaría arrancar Koin, Room y los clientes de red para comprobar que un botón se enciende.
El concepto · Quien pide y quien pinta
AuthScreen hace hoy dos trabajos a la vez: pide el ViewModel a Koin y lee de él el estado del formulario, y pinta el selector, los dos campos y el botón. Se parte en dos funciones:
AuthScreensigue siendo la que usa la app: pide elViewModel, lee su estado y se lo pasa a la otra.AuthContentes la que pinta. Recibe el estado (AuthFormState) y funciones para avisar de lo que pasa:alEscribirEmail,alEntrar,alRecuperar… No sabe que Koin existe.
Y una pieza nueva: el testTag, un nombre interno que se pone a un elemento solo para que los tests lo encuentren sin depender del texto que muestra. Lo verás fallar sin él en el paso 2.
Paso 1 · Parte AuthScreen en dos
El archivo. Es androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/auth/AuthScreen.kt, el que escribiste tú. No se pega nada encima: son cuatro cambios puntuales, y Android Studio te marca en rojo los sitios que faltan.
1 · Un import nuevo. Junto al de LocalSoftwareKeyboardController, que ya está:
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag2 · La cabecera se convierte en dos funciones. Hoy empieza así: @OptIn(ExperimentalMaterial3Api::class), @Composable, fun AuthScreen(…), y la primera línea de su cuerpo es val formulario by viewModel.form.collectAsStateWithLifecycle(). Haz dos cosas: borra la línea @OptIn(ExperimentalMaterial3Api::class) de encima de fun AuthScreen( (la necesita la otra función, no esta) y, justo debajo de la línea val formulario by …, pega este bloque. Cierra AuthScreen y abre AuthContent:
// (la línea `val formulario by viewModel.form.collectAsStateWithLifecycle()`
// ya está encima; no la repitas)
// AuthScreen reparte: le da a AuthContent el estado y, como avisos, los
// métodos del ViewModel. `viewModel::entrar` es «la función entrar de ese
// ViewModel», lista para llamarla cuando haga falta.
AuthContent(
formulario = formulario,
alEscribirEmail = viewModel::escribirEmail,
alEscribirPassword = viewModel::escribirPassword,
alEntrar = viewModel::entrar,
alRegistrar = viewModel::registrar,
alRecuperar = viewModel::recuperar,
alDescartar = viewModel::descartarError,
modifier = modifier,
)
}
/**
* La pantalla sin ViewModel: recibe el estado y devuelve eventos. Así se puede
* pintar en un test con un estado inventado, sin Koin ni red.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AuthContent(
formulario: AuthFormState,
alEscribirEmail: (String) -> Unit,
alEscribirPassword: (String) -> Unit,
alEntrar: () -> Unit,
alRegistrar: () -> Unit,
alRecuperar: () -> Unit,
alDescartar: () -> Unit,
modifier: Modifier = Modifier,
) {La línea siguiente, var registrando by remember { mutableStateOf(false) }, y todo lo demás se quedan: ahora forman el cuerpo de AuthContent. La llave } que cerraba el antiguo AuthScreen, al final de la función, ahora cierra AuthContent: no hace falta tocarla. Y AuthContent es pública (sin private) para que el test, que vive en otro fichero, la vea.
3 · Cambia los sitios que siguen en rojo. Dentro de AuthContent ya no existe viewModel, y Android Studio lo marca en seis líneas. Cada una pasa a usar el aviso que le dan:
| Dónde | Hoy | Queda |
|---|---|---|
val enviar = { … } | if (registrando) viewModel.registrar() else viewModel.entrar() | if (registrando) alRegistrar() else alEntrar() |
| campo del correo | onValueChange = viewModel::escribirEmail | onValueChange = alEscribirEmail |
| campo de la contraseña | onValueChange = viewModel::escribirPassword | onValueChange = alEscribirPassword |
| botón «¿Olvidaste tu contraseña?» | viewModel.recuperar() | alRecuperar() |
AlertDialog, onDismissRequest | viewModel::descartarError | alDescartar |
AlertDialog, botón «Entendido» | viewModel::descartarError | alDescartar |
Cuando no quede ninguna, AuthContent ya no menciona viewModel. Todo lo demás de la pantalla (los errores bajo cada campo, el orden de imePadding antes de verticalScroll, el aviso de error) se queda como lo escribiste.
4 · Las etiquetas de prueba. Cuatro constantes con el nombre de cada etiqueta, en un solo sitio y no sueltas por el fichero. Una const val es una constante que se conoce al compilar (un nombre fijo que no cambia mientras la app corre), y va fuera de cualquier función, al nivel del fichero. Ponlas justo encima del comentario que empieza por // El enum se traduce a texto (el que está sobre private fun textoDe(error: ErrorEmail), hacia el final del fichero):
/** Las etiquetas que usan los tests, en un solo sitio y no sueltas por ahi. */
const val TAG_MODO_ENTRAR = "modo-entrar"
const val TAG_MODO_REGISTRAR = "modo-registrar"
const val TAG_ENVIAR = "enviar"
const val TAG_RECUPERAR = "recuperar"Y se aplican a cuatro elementos con testTag. En cada uno añade la línea o el trozo marcado:
// Los dos SegmentedButton del selector: una línea nueva debajo de su `shape = …`.
modifier = Modifier.testTag(TAG_MODO_ENTRAR), // el de «Entrar»
modifier = Modifier.testTag(TAG_MODO_REGISTRAR), // el de «Crear cuenta»
// El Button de enviar: `.testTag(…)` al final de su modifier.
modifier = Modifier.fillMaxWidth().height(52.dp).testTag(TAG_ENVIAR),
// El TextButton de «¿Olvidaste tu contraseña?»: lo mismo.
modifier = Modifier.padding(top = 8.dp).testTag(TAG_RECUPERAR),Comprueba que compila, y que la app sigue igual:
./gradlew :androidApp:assembleDebugPaso 2 · El primer test de la pantalla, y por qué hacen falta etiquetas
Créalo. Clic derecho sobre la carpeta androidApp → New → File y esta ruta:
src/test/kotlin/com/sebastiannarvaez/pokedex/android/auth/AuthContentTest.ktEscribe la clase con su ayudante y un test. La línea que busca el botón está mal a propósito: busca el botón por su texto.
package com.sebastiannarvaez.pokedex.android.auth
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import com.sebastiannarvaez.pokedex.feature.auth.AuthFormState
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import kotlin.test.assertEquals
@RunWith(RobolectricTestRunner::class)
class AuthContentTest {
@get:Rule
val compose = createComposeRule()
// Monta AuthContent con un estado y, si el test quiere, un aviso. Los demás
// avisos son funciones vacías: este test no mira por ellos. Se llama
// `pintar` en minúscula porque es una función normal que llama a
// `setContent`, no un composable.
private fun pintar(
formulario: AuthFormState = AuthFormState(),
alEntrar: () -> Unit = {},
) {
compose.setContent {
AuthContent(
formulario = formulario,
alEscribirEmail = {},
alEscribirPassword = {},
alEntrar = alEntrar,
alRegistrar = {},
alRecuperar = {},
alDescartar = {},
)
}
}
@Test
fun `con el formulario vacio no se puede entrar`() {
pintar()
// MAL A PROPÓSITO: «Entrar» es el texto del selector Y el del botón.
compose.onNodeWithText("Entrar").assertIsNotEnabled()
}Cierra la clase con } y ejecuta:
./gradlew :androidApp:testDebugUnitTestFalla (el mensaje completo está en el informe androidApp/build/reports/tests/testDebugUnitTest/index.html, que se abre como en la lección anterior, con open en la Terminal):
Reason: Expected exactly '1' node but found '2' nodes that satisfy: (Text + InputText + EditableText contains 'Entrar' (ignoreCase: false))Dos elementos dicen «Entrar»: la pestaña del selector y el botón. Una etiqueta los distingue sin tocar lo que ve el usuario. Cambia la línea mala por la buena y ejecuta: pasa.
// El botón de enviar, por su etiqueta.
compose.onNodeWithTag(TAG_ENVIAR).assertIsNotEnabled()Paso 3 · Los otros ocho casos
Faltan ocho casos más. En vez de pegarlos a ciegas, mira antes tres que enseñan algo que aún no has escrito: uno que teclea (performTextInput), uno que pulsa un botón fuera de la ventana (performScrollTo + performClick) y uno que comprueba lo que la pantalla no dice. «La ventana» del test es el área de pantalla que simula Robolectric, del tamaño de un teléfono: lo que no cabe queda fuera de ella. Son parte del fichero completo de abajo; léelos aquí y luego pega.
Para que compilen, la función pintar del fichero completo recibe, además de alEntrar, los avisos alEscribirEmail, alEscribirPassword, alRegistrar y alRecuperar (todos con una función vacía por defecto). El que escribiste a mano solo tenía alEntrar.
@Test
fun `escribir en el campo de correo avisa al ViewModel`() {
var escrito = ""
// `alEscribirEmail` es el aviso que `AuthContent` da al ViewModel;
// aquí solo apunta lo que le llega.
pintar(alEscribirEmail = { escrito = it })
// Busca el campo por su etiqueta («Correo») y teclea en él.
compose.onNodeWithText("Correo").performTextInput("ash")
assertEquals("ash", escrito)
} @Test
fun `con correo valido la recuperacion avisa`() {
var recuperaciones = 0
pintar(
formulario = AuthFormState(email = "ash@pueblo-paleta.test"),
alRecuperar = { recuperaciones++ },
)
// Desplázate hasta el botón antes de pulsarlo: ver más abajo.
compose.onNodeWithTag(TAG_RECUPERAR).performScrollTo().performClick()
assertEquals(1, recuperaciones)
} @Test
fun `el aviso de correo enviado no dice si la cuenta existe`() {
// `correoEnviado = true` es el estado «ya pedí recuperar la contraseña».
pintar(AuthFormState(email = "ash@pueblo-paleta.test", correoEnviado = true))
compose.onNodeWithText("Revisa tu correo").assertExists()
// Lo importante es la frase: dice «SI hay una cuenta…», sin confirmar
// que la haya. Si dijera «te hemos mandado el enlace», cualquiera
// podría averiguar qué correos tienen cuenta.
compose.onNodeWithText("Si hay una cuenta con ese correo", substring = true).assertExists()
}Qué comprueba cada uno de los nueve del fichero completo:
| Test | Qué comprueba |
|---|---|
| formulario vacío | el botón de enviar está deshabilitado |
| correo y contraseña válidos | el botón se habilita |
| escribir en el campo de correo | teclear avisa al ViewModel con el texto escrito |
| pulsar entrar | llama a «entrar» y no a «registrar» |
| tras elegir crear cuenta | el mismo botón ahora registra |
| el error se pinta | se ven el título y el detalle del error |
| sin correo válido | «recuperar» está deshabilitado |
| con correo válido | «recuperar» avisa al pulsarlo |
| correo enviado | el aviso no revela si la cuenta existe |
Pega el fichero completo encima del tuyo (abre AuthContentTest.kt, pulsa Copiar fichero, selecciona todo y pega):
Paso 4 · La lista completa y el desplazamiento
El fichero de la lista que escribiste en la lección anterior tiene tres tests. Este lo sustituye y no pierdes nada: trae tus tres tests (el primero ampliado a dos Pokémon, para ver el desplazamiento) y suma el cuarto, que comprueba que un fallo de red ofrece reintentar. Su Pintar deja elegir en cada test los favoritos y el aviso de marcar, que el tuyo dejaba fijos (vacío y sin hacer nada); los tests que ya tenías no lo notan.
Mira el cuarto antes de pegar, porque trae algo nuevo: cómo se finge un fallo de red.
@Test
fun `un fallo de red ofrece reintentar`() {
// Una lista sin Pokémon cuyo estado de carga inicial es `Error`: es
// lo que Paging cuenta cuando la carga falla. `IOException` es el error
// típico de la red (conexión cortada); el texto es lo de menos.
val conError = PagingData.from<Pokemon>(
emptyList(),
LoadStates(
refresh = LoadState.Error(java.io.IOException("socket cerrado")),
prepend = LoadState.NotLoading(true),
append = LoadState.NotLoading(true),
),
)
compose.setContent { Pintar(conError) }
compose.onNodeWithText("Reintentar").assertIsDisplayed()
}Sin red de verdad ni servidor: el test inventa el fallo y comprueba que la pantalla ofrece «Reintentar».
Dos detalles que ya están dentro, y que conviene entender. En la lista, el segundo Pokémon cae fuera de la ventana del test (el área que simula Robolectric, como acabas de ver). En una rejilla perezosa eso no es «está oculto»: es que no existe, porque Compose no lo ha compuesto todavía. Hay que desplazar la rejilla hasta él. hasScrollAction() es un buscador que encuentra «el nodo que se puede desplazar» (la rejilla) y performScrollToNode(hasText("Charmander")) la desplaza hasta que aparezca un nodo con ese texto:
compose.onNode(hasScrollAction()).performScrollToNode(hasText("Charmander"))Y en la pantalla de entrada, el botón de recuperar puede quedar por debajo del borde de una ventana de teléfono. Ahí el nodo sí existe (la columna normal lo compone todo), pero está fuera del área visible, y se llega con performScrollTo(). Resumen: el nodo ya compuesto se desplaza con performScrollTo(); el que todavía no existe se alcanza desde su contenedor con performScrollToNode(…).
Provócalo tú. En AuthContentTest.kt, en el test con correo valido la recuperacion avisa, quita .performScrollTo() de esta línea y ejecuta:
compose.onNodeWithTag(TAG_RECUPERAR).performScrollTo().performClick()Falla con un mensaje que no habla del clic:
java.lang.AssertionError: Expected <1>, actual <0>.Habla del contador que el test comprueba al final: esperaba un aviso y hubo cero. Nada dice que el botón estaba fuera de la ventana. La costumbre que vale: desplázate hasta el nodo antes de interactuar con él, siempre. Vuelve a poner .performScrollTo().
Ejecútalo todo
./gradlew :androidApp:testDebugUnitTest
open androidApp/build/reports/tests/testDebugUnitTest/index.htmlopen abre el informe en el navegador (es la orden de macOS que ya usaste en la lección anterior). El recuadro tests del informe debe decir 13: 9 de AuthContentTest y 4 de PokemonListContentTest, con 0 fallos.
Punto de control
./gradlew :androidApp:testDebugUnitTesttermina enBUILD SUCCESSFULy el informe (androidApp/build/reports/tests/testDebugUnitTest/index.html) cuenta 13 tests con 0 fallos: 9 deAuthContentTesty 4 dePokemonListContentTest.- Los tests del núcleo no cambian:
:shared:jvmTest,:shared:testAndroidHostTesty:shared:iosSimulatorArm64Testsiguen en 127, 89 y 92 (la última solo corre en un Mac). AuthScreen.kttieneAuthScreenyAuthContent, con las cuatro etiquetasTAG_…, y sigue enseñando el error bajo cada campo y el teclado sin tapar «Entrar».- La app arranca en el emulador y la pantalla de entrada funciona igual que antes: partir una pantalla no cambia lo que se ve.
Comprueba lo que entendiste
6 preguntas · necesitas 5 para completar la lección
1.En una rejilla perezosa, un elemento que cae fuera de la ventana…
2.Dos elementos de la pantalla dicen «Entrar»: el selector y el botón. ¿Cómo encuentra el test el botón y no el selector?
3.¿Por qué se parte la pantalla de entrada en
AuthScreenyAuthContent?4.El botón de recuperar existe, pero queda por debajo del borde de la ventana del test. ¿Qué hace falta antes de pulsarlo?
5.Quitas
performScrollTo()del test de recuperar y falla conExpected <1>, actual <0>. ¿Qué dice ese mensaje?6.¿Cómo comprueba el test de la lista que un fallo de red ofrece «Reintentar», sin red ni servidor?