Saltar al contenido
K
CalidadPaso a paso27 min Avanzado

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.

androidApp/src
main/kotlin/com/sebastiannarvaez/pokedex/android/auth/AuthScreen.ktse modifica (paso 1)
test/kotlin/com/sebastiannarvaez/pokedex/android/auth/AuthContentTest.ktse crea (pasos 2 y 3)
test/kotlin/com/sebastiannarvaez/pokedex/android/list/PokemonListContentTest.ktse sustituye (paso 4)
Qué se modifica y qué se crea, con las rutas de la vista Project.

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:

  • AuthScreen sigue siendo la que usa la app: pide el ViewModel, lee su estado y se lo pasa a la otra.
  • AuthContent es 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á:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/auth/AuthScreen.kt: el import nuevo
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag

2 · 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:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/auth/AuthScreen.kt: AuthScreen corta y la cabecera de 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óndeHoyQueda
val enviar = { … }if (registrando) viewModel.registrar() else viewModel.entrar()if (registrando) alRegistrar() else alEntrar()
campo del correoonValueChange = viewModel::escribirEmailonValueChange = alEscribirEmail
campo de la contraseñaonValueChange = viewModel::escribirPasswordonValueChange = alEscribirPassword
botón «¿Olvidaste tu contraseña?»viewModel.recuperar()alRecuperar()
AlertDialog, onDismissRequestviewModel::descartarErroralDescartar
AlertDialog, botón «Entendido»viewModel::descartarErroralDescartar

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

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/auth/AuthScreen.kt: las cuatro etiquetas
/** 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:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/auth/AuthScreen.kt: cuatro elementos con etiqueta
// 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:assembleDebug

Paso 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.kt

Escribe 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.

androidApp/src/test/kotlin/com/sebastiannarvaez/pokedex/android/auth/AuthContentTest.kt: la clase y su ayudante
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:testDebugUnitTest

Falla (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.

androidApp/src/test/kotlin/com/sebastiannarvaez/pokedex/android/auth/AuthContentTest.kt: la línea buena
        // 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.

ejemplo del fichero completo: un test que teclea
    @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)
    }
ejemplo del fichero completo: un test que pulsa «recuperar»
    @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)
    }
ejemplo del fichero completo: un test que comprueba lo que NO se dice
    @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:

TestQué comprueba
formulario vacíoel botón de enviar está deshabilitado
correo y contraseña válidosel botón se habilita
escribir en el campo de correoteclear avisa al ViewModel con el texto escrito
pulsar entrarllama a «entrar» y no a «registrar»
tras elegir crear cuentael mismo botón ahora registra
el error se pintase 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 enviadoel aviso no revela si la cuenta existe

Pega el fichero completo encima del tuyo (abre AuthContentTest.kt, pulsa Copiar fichero, selecciona todo y pega):

AuthContentTest.kt

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.

ejemplo del fichero completo: el cuarto test de la lista
    @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».

PokemonListContentTest.kt

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:

androidApp/src/test/kotlin/com/sebastiannarvaez/pokedex/android/list/PokemonListContentTest.kt: el desplazamiento de la rejilla
        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:

la línea que vas a tocar
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.html

open 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:testDebugUnitTest termina en BUILD SUCCESSFUL y el informe (androidApp/build/reports/tests/testDebugUnitTest/index.html) cuenta 13 tests con 0 fallos: 9 de AuthContentTest y 4 de PokemonListContentTest.
  • Los tests del núcleo no cambian: :shared:jvmTest, :shared:testAndroidHostTest y :shared:iosSimulatorArm64Test siguen en 127, 89 y 92 (la última solo corre en un Mac).
  • AuthScreen.kt tiene AuthScreen y AuthContent, con las cuatro etiquetas TAG_…, 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. 1.En una rejilla perezosa, un elemento que cae fuera de la ventana…

  2. 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. 3.¿Por qué se parte la pantalla de entrada en AuthScreen y AuthContent?

  4. 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. 5.Quitas performScrollTo() del test de recuperar y falla con Expected <1>, actual <0>. ¿Qué dice ese mensaje?

  6. 6.¿Cómo comprueba el test de la lista que un fallo de red ofrece «Reintentar», sin red ni servidor?

Faltan 6 de 6