wandres.dev
TESTING · unidad, UI y dispositivo

Tests instrumentados: Espresso, UI Automator, permisos y notificaciones

Hay una frontera que ninguna prueba en la máquina virtual atraviesa: la que separa tu proceso del resto del sistema. Esta lección se ocupa de lo que solo existe al otro lado. Explica el modelo de sincronización de Espresso y por qué su fiabilidad depende de que todo trabajo asíncrono se declare, cómo conviven Espresso y las afirmaciones de Compose en pantallas híbridas, qué añade UI Automator al permitir salir de la aplicación y operar sobre la interfaz del propio sistema operativo, y cómo se prueban en serio las dos superficies que más defectos generan en producción y menos cobertura tienen: la concesión y denegación de permisos en tiempo de ejecución y las notificaciones publicadas de verdad en el cajón.

⏱ 21 min

Todo lo anterior de este nivel se ejecuta dentro de un espacio que controlas por completo, y esa comodidad tiene un límite preciso: termina donde empieza el sistema operativo. El diálogo que pide acceso a la cámara no lo dibuja tu aplicación sino un proceso distinto con su propio paquete y su propio idioma. La notificación que publicas deja de pertenecerte en cuanto sale, y quien decide cómo se ve, si suena y en qué posición aparece es el gestor del sistema. La aplicación de la cámara a la que envías un intent es de otro fabricante. Nada de eso existe bajo Robolectric ni puede existir, porque no es una parte del framework que se pueda reimplementar: es otro proceso. Las pruebas instrumentadas son la única herramienta que cruza esa frontera, y esta lección trata de las dos que la cruzan de formas distintas —Espresso dentro de tu proceso y UI Automator fuera de él— y de las dos superficies donde esa capacidad se vuelve imprescindible.

🎯 Al terminar esta lección sabrás
  • Explicar el modelo de sincronización de Espresso y declarar el trabajo asíncrono que no detecta por su cuenta.
  • Combinar afirmaciones de vistas clásicas y de Compose en una misma prueba sobre pantalla híbrida.
  • Operar con UI Automator sobre la interfaz del sistema y sobre otras aplicaciones.
  • Probar en serio la concesión, la denegación y la denegación permanente de permisos, y verificar notificaciones publicadas.

Espresso: la cola de mensajes como reloj

Espresso se construye sobre una idea sencilla y muy bien ejecutada: antes de interactuar con una vista o de afirmar sobre ella, esperar a que el hilo principal no tenga nada pendiente. Comprueba que la cola de mensajes está vacía, que no hay mensajes programados para el futuro inmediato y que los recursos de espera registrados se declaran en reposo. Solo entonces ejecuta la acción. De ahí viene su reputación de fiabilidad y también su tríada de operaciones, que se lee casi como una frase: localizar una vista con un emparejador, ejecutar sobre ella una acción, comprobar sobre ella una aserción.

@get:Rule val escenario = ActivityScenarioRule(MainActivity::class.java)

@Test fun alGuardarSeMuestraElMensaje() {
    onView(withId(R.id.campo_titulo)).perform(typeText("Compra"), closeSoftKeyboard())
    onView(withId(R.id.boton_guardar)).perform(click())
    onView(withText(R.string.nota_guardada)).check(matches(isDisplayed()))
}

El punto donde esta garantía se rompe es siempre el mismo: el trabajo asíncrono que no vive en la cola de mensajes del hilo principal. Una llamada de red, una consulta a disco o una corrutina en otro despachador son invisibles para el mecanismo, que declara reposo mientras el resultado todavía no ha llegado. La solución idiomática no es esperar, es declarar: un recurso de espera es un objeto que responde si hay trabajo en curso y avisa cuando termina, y al registrarlo se incorpora a la condición de reposo que Espresso ya comprueba. La contrapartida es que hay que instrumentar el código de producción para exponer ese estado, lo cual desagrada a mucha gente con razón, y por eso conviene reservarlo para las fronteras que de verdad no se pueden controlar desde la prueba.

Dos extensiones merecen mención porque resuelven problemas que de otro modo obligan a salir del marco. La de intents permite afirmar que se emitió un intent con determinada acción y datos, y sobre todo permite responder a un intent saliente con un resultado prefabricado: es la forma de probar el flujo de tomar una foto o elegir un contacto sin depender de la aplicación real que lo atendería. La de listas aporta acciones para desplazar y actuar sobre una posición concreta de un RecyclerView, porque el emparejador ordinario solo ve lo que está presente en la jerarquía y una fila fuera de pantalla sencillamente no existe.

La convivencia con Compose es más limpia de lo que se suele suponer. La regla de composición ligada a una actividad no impide usar Espresso en la misma prueba: las vistas clásicas se interrogan con una API y los composables con la otra, y ambas comparten la misma sincronización subyacente. Es exactamente lo que hace falta durante una migración larga, donde una pantalla tiene la barra en vistas y el contenido en Compose.

⚠️
Los diálogos del sistema no están en tu jerarquía

Cuando una prueba con Espresso falla diciendo que no encuentra una vista que se está viendo claramente en la pantalla, la causa casi siempre es que esa vista pertenece a otro proceso: un diálogo de permisos, un selector de aplicaciones, una hoja de compartir. Espresso solo ve la jerarquía de tu propia aplicación, y para todo lo demás la herramienta correcta es otra.

UI Automator: salir de la aplicación

UI Automator opera en un plano distinto. No conoce tu código ni tus identificadores de recurso: conversa con el servicio de accesibilidad del sistema y por tanto puede ver y manipular cualquier cosa que haya en pantalla, venga del proceso que venga. Puede pulsar el botón de inicio, abrir el cajón de notificaciones, entrar en los ajustes, responder a un diálogo de permisos, cambiar de aplicación y volver.

private val dispositivo = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())

@Test fun laNotificacionAbreElDetalle() {
    dispararNotificacionDePrueba()

    dispositivo.openNotification()
    dispositivo.wait(Until.hasObject(By.textContains("Compra")), 5_000)
    dispositivo.findObject(By.textContains("Compra")).click()

    composeTestRule.onNodeWithText("Detalle de la nota").assertIsDisplayed()
}

El precio de esa potencia es que se pierde toda sincronización automática. UI Automator no sabe cuándo el sistema terminó de animar el cajón ni cuándo la otra aplicación acabó de cargar, así que cada búsqueda debe ir acompañada de una espera con condición y un límite. La regla operativa es que ninguna llamada a buscar objeto debe escribirse sin una espera previa por la condición correspondiente, y que ninguna espera debe ser una pausa por tiempo.

El segundo precio es la fragilidad de los selectores. Buscar por texto visible funciona hasta que el idioma del dispositivo cambia o el fabricante reescribe la cadena; buscar por identificador de recurso del paquete del sistema es más estable pero varía entre versiones de Android. La práctica razonable es encapsular esos selectores en un único objeto auxiliar de la suite, con variantes por nivel de API, de modo que una migración de versión toque un fichero y no cuarenta pruebas.

🎯

Espresso

Dentro de tu proceso, con vistas clásicas. Sincroniza solo. Máxima fiabilidad y mínima cobertura del sistema.

🌐

UI Automator

Cualquier proceso, incluida la interfaz del sistema. Sin sincronización. Máxima cobertura y máxima fragilidad.

🧩

Regla de Compose

Composables dentro de tu proceso, con espera automática. Convive con las dos anteriores en la misma prueba.

🖥️

Instrumentación directa

Comandos del intérprete y consultas al gestor del sistema. Más rápida y más estable que pulsar botones ajenos.

Permisos: conceder, denegar y probar la denegación permanente

Los permisos en tiempo de ejecución generan una cantidad desproporcionada de defectos en producción por una razón estructural: el camino feliz se prueba a mano una vez y los otros tres caminos no se prueban nunca. Y son cuatro estados distintos con comportamientos distintos: concedido, denegado una vez, denegado con la casilla de no volver a preguntar, y revocado por el sistema tras meses sin uso de la aplicación.

Para el estado concedido existe una regla que otorga el permiso antes de que la prueba arranque, de modo que el diálogo no llega a aparecer. Es lo correcto cuando lo que se quiere probar es la funcionalidad que hay detrás del permiso y no el flujo de solicitud.

@get:Rule val permisos = GrantPermissionRule.grant(Manifest.permission.CAMERA)

Para probar el flujo de solicitud hay que responder al diálogo real, y ese diálogo pertenece al paquete del controlador de permisos. Buscarlo por el texto del botón funciona en un idioma y falla en todos los demás, así que la forma robusta es localizarlo por su identificador de recurso, que es estable dentro de un rango amplio de versiones.

private fun responderAlDialogoDePermiso(permitir: Boolean) {
    val id = if (permitir) "com.android.permissioncontroller:id/permission_allow_button"
             else "com.android.permissioncontroller:id/permission_deny_button"
    val boton = dispositivo.wait(Until.findObject(By.res(id)), 5_000)
    boton?.click()
}

Para la denegación permanente conviene evitar por completo la interfaz y actuar sobre el estado. Los comandos de concesión y revocación del gestor de paquetes se pueden ejecutar desde la propia instrumentación, y ahí hay un detalle que sorprende la primera vez y que hay que conocer: revocar un permiso concedido mata el proceso de la aplicación, exactamente igual que en producción. Esa muerte no es un fallo de la prueba, es la parte más valiosa de ella, porque reproduce el escenario real que la mayoría de las aplicaciones no maneja: volver del sistema de ajustes a un proceso recreado desde cero.

InstrumentationRegistry.getInstrumentation().uiAutomation
    .executeShellCommand("pm revoke $paquete android.permission.CAMERA")
💡
Actúa sobre el estado y reserva la interfaz para lo que estés probando

Preparar un escenario pulsando botones del sistema es lento y frágil. Si lo que se quiere probar es el comportamiento de la pantalla cuando el permiso está denegado permanentemente, conviene llegar a ese estado por comando y usar la interfaz solo para lo que constituya el objeto de la prueba. La interfaz del sistema es un medio de verificación, no un buen medio de preparación.

flowchart TD
A[Que necesita observar la prueba] --> B{Esta dentro de mi proceso}
B -->|si, vistas clasicas| C[Espresso]
B -->|si, composables| D[Regla de Compose]
B -->|no| E{Puedo consultarlo por API o por comando}
E -->|si| F[Instrumentacion directa o gestor del sistema]
E -->|no| G[UI Automator con espera por condicion]
style C fill:#a6e3a1,color:#11111b
style F fill:#89b4fa,color:#11111b
style G fill:#f9e2af,color:#11111b

Notificaciones: verificar lo que el sistema publicó de verdad

Hay dos formas de comprobar una notificación y conviene usar las dos porque responden a preguntas distintas. La primera pregunta es si la aplicación publicó lo que debía, y se responde consultando al gestor de notificaciones por las notificaciones activas del propio paquete. Es rápido, no depende del idioma, no requiere abrir ningún cajón y permite afirmar sobre el canal, el identificador, el título, el texto y las acciones.

@Test fun publicaLaNotificacionEnElCanalCorrecto() {
    val gestor = contexto.getSystemService(NotificationManager::class.java)
    servicio.avisarDeSincronizacionCompleta()

    val activa = gestor.activeNotifications.single { it.id == ID_SINCRONIZACION }
    assertThat(activa.notification.channelId).isEqualTo("sincronizacion")
    assertThat(activa.notification.extras.getString(Notification.EXTRA_TITLE))
        .isEqualTo("Sincronizacion completada")
}

La segunda pregunta es si al pulsarla ocurre lo correcto, y esa solo se responde desde el cajón con UI Automator, porque el camino auténtico atraviesa el sistema: el intent pendiente se dispara desde otro proceso, la pila de actividades se reconstruye y la aplicación puede estar muerta en ese momento. Es justamente ese camino el que suele estar roto, y de una forma característica: la notificación abre la pantalla correcta pero el botón de retroceso saca de la aplicación en lugar de subir a la pantalla padre, porque la pila sintética nunca se construyó.

Merece señalarse el permiso de notificaciones introducido en Android 13, que reintroduce todos los estados de la sección anterior en un flujo que antes no los tenía. La prueba mínima honesta comprueba tres cosas: que la aplicación pide el permiso en un momento con contexto y no en el arranque, que degrada con sensatez cuando se deniega en lugar de publicar al vacío, y que sigue funcionando cuando el permiso se revoca estando la aplicación instalada desde hace meses.

La frontera del proceso es la única que no se puede simular

Conviene extraer el principio que ordena esta lección entera, porque decide correctamente casi todas las preguntas de qué probar y dónde, y sigue siendo válido cuando cambien las bibliotecas. Todo lo que una prueba puede simular pertenece a un mismo conjunto: lo que se ejecuta dentro de tu proceso y bajo tu código. La base de datos se puede poner en memoria, el servidor se puede meter en el proceso, el framework se puede reimplementar con sombras, el reloj se puede inyectar. Nada de eso funciona con lo que vive al otro lado de la frontera del proceso, y el motivo no es una limitación de las herramientas sino una propiedad del sistema operativo: el controlador de permisos, el gestor de notificaciones, la aplicación de cámara del fabricante y el gestor de actividades no son bibliotecas que hayas enlazado, son programas independientes con su propio ciclo de vida, su propia política y su propia versión, que pueden matar tu proceso, denegarte lo que pides o comportarse distinto en el dispositivo de un fabricante concreto. La consecuencia es una asignación de recursos que suena contraintuitiva y que sostengo con firmeza: la prueba instrumentada es la más cara de todas, por lo que su presupuesto debe gastarse exclusivamente en aquello que solo ella puede ver, y prácticamente todo lo que hoy ocupa las suites instrumentadas de la industria no cumple ese criterio. Verificar que al pulsar un botón cambia un texto no necesita un emulador; verificar que tu aplicación sobrevive a que el sistema la mate mientras el usuario le concede un permiso, sí, y no hay ninguna otra manera de averiguarlo. De ahí sale una prueba de descarte que conviene aplicar a cada caso instrumentado que ya existe: preguntarse si el escenario podría ocurrir sin que el sistema operativo participe activamente. Si la respuesta es que sí, el caso está mal colocado y está pagando el precio más alto por información que se obtenía gratis una capa más abajo. Y si la respuesta es que no, entonces se trata de una de las pocas pruebas verdaderamente insustituibles de todo el proyecto, y probablemente sea también una de las que hoy no está escrita.

⚔️ Cubre la frontera del sistema
  1. Audita tu suite instrumentada y clasifica cada prueba según si el sistema operativo participa de verdad. Baja de capa todas las que no lo requieran.
  2. Escribe las cuatro pruebas de permiso para tu permiso más crítico: concedido, denegado una vez, denegado permanentemente y revocado con el proceso muerto.
  3. Encapsula todos tus selectores de interfaz del sistema en un único objeto auxiliar con variantes por nivel de API.
  4. Verifica una notificación por las dos vías: consulta de notificaciones activas para el contenido y UI Automator para el camino de pulsación.
  5. Comprueba que el retroceso desde una pantalla abierta por notificación lleva a la pantalla padre y no fuera de la aplicación.