Tests fiables: matar la inestabilidad, aislar el estado y correr en CI
Una suite que falla al azar es peor que no tener suite, porque consume el mismo tiempo y además destruye la única propiedad que hacía útil el color rojo: que significara algo. Esta lección cuantifica por qué una tasa de inestabilidad individual minúscula produce una suite que casi nunca pasa, enumera las cuatro fuentes reales de no determinismo en Android y la corrección estructural de cada una, explica cómo se consigue aislamiento auténtico entre pruebas instrumentadas con un proceso por caso y borrado de datos del paquete, y monta una ejecución en integración continua con dispositivos gestionados, particionado y artefactos de diagnóstico. Cierra con la política de cuarentena que sustituye al reintento silencioso.
Una prueba inestable no es una prueba con un defecto menor: es una prueba que ha dejado de transmitir información. Cuando el rojo significa a veces que el código está mal y a veces que el emulador tuvo un mal día, la respuesta racional de cualquier equipo es volver a lanzarla, y a partir de ese momento la suite ya no protege nada aunque siga ejecutándose. Lo grave es que este deterioro no requiere pruebas muy malas: la aritmética es implacable y basta con una tasa individual de fallo espurio ridículamente pequeña para que una suite de tamaño normal casi nunca termine en verde. Esta lección se ocupa de eso con la seriedad que merece, porque es la diferencia entre un nivel de testing que produce confianza y uno que produce ruido caro. Primero la aritmética, después las cuatro causas reales y sus correcciones estructurales, después el aislamiento de verdad entre pruebas, y por último la ejecución en integración continua con emuladores y una política de cuarentena que no consista en mirar hacia otro lado.
- Cuantificar el efecto agregado de la inestabilidad y justificar por qué exige tolerancia cero.
- Identificar las cuatro fuentes de no determinismo en Android y aplicar la corrección estructural de cada una.
- Conseguir aislamiento real entre pruebas instrumentadas con proceso por caso y borrado de datos.
- Montar la ejecución en integración continua con dispositivos gestionados, particionado y artefactos de diagnóstico.
La aritmética que obliga a la tolerancia cero
Supongamos una suite de quinientas pruebas y una tasa de fallo espurio del uno por mil en cada una, que suena excelente. La probabilidad de que la suite entera pase es el producto de las probabilidades individuales, que es aproximadamente sesenta y uno por ciento. Es decir, dos de cada cinco ejecuciones fallan sin que nadie haya roto nada. Con dos mil pruebas y la misma tasa, la suite pasa el catorce por ciento de las veces. La conclusión es incómoda y hay que asumirla: en una suite grande, la inestabilidad individual aceptable es cero, porque cualquier valor positivo se multiplica hasta dominar el resultado.
El coste de esa situación no se paga en tiempo de máquina sino en un cambio de comportamiento humano que es difícil de revertir. Cuando el rojo deja de ser informativo, el equipo aprende a ignorarlo, y ese aprendizaje no distingue entre el rojo espurio y el rojo verdadero. La suite pasa entonces a tener valor negativo: cuesta escribirla, cuesta mantenerla, cuesta ejecutarla y ya no impide que un defecto llegue a producción, porque el fallo que lo señalaba se descartó junto con los demás.
De ahí se sigue la política que sostiene todo lo demás y que conviene enunciar antes que las técnicas: una prueba inestable se saca de la suite el mismo día en que se detecta, se registra como defecto y se corrige o se elimina. Lo que no se hace nunca es dejarla dentro con un reintento automático, porque el reintento no elimina el problema, elimina la señal.
El reintento automático parece pragmático y en el caso de la infraestructura genuinamente inestable a veces lo es. Pero aplicado como política general convierte un defecto reproducible en un defecto invisible, y hay una clase de fallo que se pierde con él y que es exactamente la peor: la condición de carrera real del código de producción, que también se manifiesta como fallo intermitente. Si reintentas hasta que pase, has decidido que esa clase de defecto llegue al usuario.
Las cuatro fuentes de no determinismo
La primera es el tiempo. Toda espera por duración fija es una apuesta contra la carga de la máquina, y la máquina de integración continua siempre está más cargada que la de desarrollo. La corrección no es aumentar el margen sino eliminar la dependencia: despachadores inyectados y controlados desde la prueba, una fuente de tiempo sustituible en lugar del reloj del sistema, y espera por condición donde de verdad haya que esperar.
class DespachadorPrincipalRule(
private val despachador: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
override fun starting(description: Description) = Dispatchers.setMain(despachador)
override fun finished(description: Description) = Dispatchers.resetMain()
}
La segunda es el estado compartido. Objetos únicos con estado, cachés en proceso, ficheros de preferencias, bases de datos en disco y el propio almacén de datos persisten entre pruebas dentro de un mismo proceso, y eso crea dependencia de orden: la prueba pasa aislada y falla cuando se ejecuta después de otra. Es la causa más frecuente de todas y la más difícil de diagnosticar, porque el fallo aparece en la prueba inocente. El diagnóstico barato consiste en ejecutar la suite en orden aleatorio de forma habitual; si el resultado cambia con el orden, hay estado filtrándose.
La tercera es la concurrencia real del código de producción. Aquí la inestabilidad es un hallazgo y no un defecto de la prueba: si una prueba falla una vez de cada cincuenta por una carrera auténtica, la prueba está haciendo su trabajo y quien tiene el defecto es el código. Distinguir esta causa de las demás es lo que justifica no reintentar nunca por costumbre.
La cuarta es el entorno: emuladores con memoria justa, animaciones activadas, teclado en pantalla que tapa elementos, diálogos del sistema que roban el foco, actualizaciones de servicios de Google en segundo plano y la red del agente de integración continua. La corrección es fijar el entorno por configuración explícita en lugar de heredarlo, y desactivar las animaciones del sistema es el ajuste que más fallos elimina por línea escrita.
flowchart TD
A[Fallo intermitente] --> B{Cambia al ejecutar en orden aleatorio}
B -->|si| C[Estado compartido entre pruebas]
B -->|no| D{Depende de esperas por tiempo}
D -->|si| E[Reloj y despachadores no controlados]
D -->|no| F{Falla tambien de forma aislada y repetida}
F -->|si| G[Carrera real en el codigo de produccion]
F -->|no| H[Entorno del emulador o del agente]
style G fill:#f38ba8,color:#11111b
style C fill:#f9e2af,color:#11111bAislamiento de verdad entre pruebas instrumentadas
En pruebas instrumentadas el aislamiento por convención no funciona, porque todas las pruebas de un mismo módulo comparten proceso por defecto y con él comparten memoria estática, ficheros y bases de datos. El orquestador de pruebas resuelve esto de raíz: ejecuta cada caso en su propia invocación de la instrumentación, de modo que un fallo catastrófico no arrastra al resto de la clase, y con la opción de borrado de datos del paquete deja el almacenamiento como estaba tras la instalación.
android {
defaultConfig {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments["clearPackageData"] = "true"
}
testOptions {
execution = "ANDROIDX_TEST_ORCHESTRATOR"
animationsDisabled = true
}
}
El precio es tiempo: arrancar un proceso por caso cuesta del orden de un segundo, lo que sobre una suite grande se nota. Es un precio que casi siempre vale la pena, y el argumento es que la alternativa no es una suite más rápida sino una suite más rápida cuyo resultado no es fiable. Cuando el coste se vuelve prohibitivo, la solución correcta es reducir el número de pruebas instrumentadas bajándolas de capa, no renunciar al aislamiento.
Queda una fuente de contaminación que el orquestador no cubre: el servidor de la aplicación en pruebas de extremo a extremo, y cualquier estado remoto. Ahí el aislamiento es responsabilidad de la prueba, y la disciplina consiste en que cada caso cree los datos que necesita con identificadores únicos por ejecución en lugar de asumir un estado inicial, porque el estado inicial compartido es exactamente lo que dos ejecuciones paralelas se pisan.
Orden aleatorio
Ejecuta la suite en orden variable de forma habitual. Si el resultado depende del orden, hay estado filtrándose entre pruebas.
Datos borrados
Un proceso por caso y borrado del paquete convierten el aislamiento en una propiedad de la infraestructura y no de la disciplina.
Sin animaciones
Desactivarlas en el dispositivo elimina de golpe una familia entera de fallos por sincronización en vistas clásicas.
Repetición dirigida
Ante una sospecha, ejecuta el caso doscientas veces seguidas. La inestabilidad se mide, no se estima de memoria.
Ejecutar en integración continua sin sufrir
Los dispositivos gestionados por el sistema de construcción son la pieza que convierte la ejecución con emuladores de artesanía en configuración versionada. Se declara el perfil, el nivel de API y la fuente de imagen del sistema, y la herramienta se encarga de descargar, arrancar, ejecutar y destruir. La consecuencia importante es que el banco de dispositivos deja de depender de lo que cada persona tenga instalado y pasa a estar en el repositorio como cualquier otro código.
android {
testOptions {
managedDevices {
localDevices {
create("pixel6api34") {
device = "Pixel 6"
apiLevel = 34
systemImageSource = "aosp-atd" // imagen ligera para pruebas
}
}
}
emulatorSnapshots { enableForTestFailures = true }
}
}
La elección de imagen del sistema tiene más impacto del que parece. Las imágenes optimizadas para pruebas quitan aplicaciones y servicios que no hacen falta, arrancan bastante más rápido y consumen mucha menos memoria, lo que reduce a la vez el tiempo y la inestabilidad por recursos. Si la aplicación necesita los servicios de Google hay que usar una imagen que los incluya, y conviene fijar su versión, porque una actualización automática en mitad de una ejecución es una causa clásica de fallos inexplicables.
Sobre el tiempo total, la palanca correcta es el particionado y no la optimización de cada caso. La instrumentación acepta dividir la suite en particiones e indicar cuál ejecutar, lo que permite repartir el trabajo entre agentes en paralelo con reparto automático. Con cuatro particiones, una suite de veinte minutos baja a algo más de cinco, y el coste marginal en máquinas suele ser mucho menor que el coste de la espera humana.
./gradlew pixel6api34DebugAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.numShards=4 \
-Pandroid.testInstrumentationRunnerArguments.shardIndex=$INDICE_DEL_AGENTE
Conviene además fijar por escrito qué hace la integración continua en cada evento, porque no todo tiene que correr siempre. Una división que funciona bien reserva la suite local completa para cada propuesta de cambio, las pruebas instrumentadas de los flujos críticos también para cada propuesta, y la suite instrumentada entera para la integración en la rama principal y para la ejecución nocturna. Lo que nunca debe ocurrir es lo contrario: descubrir en la rama principal un fallo que la suite corta habría detectado veinte minutos antes.
Falta la parte que casi todos los equipos descubren tarde: cuando algo falla en integración continua, hace falta poder averiguar por qué sin reproducirlo localmente. El mínimo indispensable son tres artefactos publicados en cada ejecución fallida: el informe de pruebas, el registro del sistema del dispositivo acotado a la ventana del fallo y una captura de pantalla tomada en el momento exacto, que se consigue con una regla que observa el fallo y guarda la imagen antes de que la actividad se destruya. Sin esos tres artefactos, cada fallo remoto cuesta una tarde.
Registra por cada prueba cuántas veces pasó y falló en las últimas cien ejecuciones. Esa tabla convierte una discusión de opiniones en una lista ordenada: las cinco peores concentran casi todo el ruido, y arreglarlas suele devolver la confianza en la suite entera. Sin esa métrica, la inestabilidad es una sensación difusa que siempre pierde frente a cualquier otra prioridad.
Merece cerrar el nivel con lo que subyace a todo lo anterior, porque reordena la forma habitual de valorar una suite y es lo único de esta lección que seguirá siendo cierto dentro de diez años. La métrica con la que casi todo el mundo juzga sus pruebas es la cobertura, y la cobertura mide una propiedad del código, no una propiedad de la información. Lo que una suite produce en realidad es un bit por ejecución, y ese bit solo tiene valor en la medida en que reduce incertidumbre sobre si el sistema funciona. La cantidad de información transmitida depende de la fiabilidad del canal, y un canal que se equivoca con probabilidad apreciable transmite mucho menos de lo que su ancho de banda sugiere; en el límite, un canal que falla la mitad de las veces no transmite absolutamente nada, por muchas afirmaciones que contenga. De ahí sale la conclusión que quiero dejar por encima de cualquier técnica concreta: la variable que hay que optimizar en una suite no es cuántos caminos recorre sino cuánto crédito merece su veredicto, y esas dos variables compiten entre sí con más frecuencia de la que se admite. Añadir una prueba de extremo a extremo que cubre un flujo entero pero falla espuriamente una de cada treinta ejecuciones no aumenta la información del sistema, la reduce, porque contamina el veredicto agregado del que dependen todas las demás. Esto explica un fenómeno que de otro modo parece paradójico: equipos con cobertura del ochenta por ciento que envían defectos evidentes a producción, y equipos con cobertura del cuarenta que casi no lo hacen. La diferencia no está en cuánto código recorrieron sino en si alguien mira el resultado y lo cree. Y de ahí se sigue la única prioridad correcta cuando hay que elegir dónde invertir el próximo día de trabajo en pruebas: no está en escribir el caso que falta, está en devolverle credibilidad al rojo. Una suite pequeña en la que todo el mundo confía detiene despliegues malos; una suite grande en la que nadie confía es un impuesto que se paga en cada cambio a cambio de una sensación de seguridad que no está respaldada por nada.
- Ejecuta tu suite completa veinte veces sin cambiar una línea de código y calcula la tasa real de fallo espurio por prueba y agregada.
- Activa el orden aleatorio de ejecución y anota qué pruebas cambian de resultado: esas dependen de estado compartido.
- Activa el orquestador con borrado de datos del paquete y desactiva las animaciones. Vuelve a medir la tasa y compara.
- Sustituye toda espera por duración fija por espera con condición o por despachador de prueba inyectado, empezando por las cinco pruebas más ruidosas.
- Declara tu banco de dispositivos gestionados en el repositorio, reparte la suite en cuatro particiones y publica informe, registro del sistema y captura de pantalla en cada fallo.
- Implanta la cuarentena: toda prueba con inestabilidad demostrada sale de la suite el mismo día, con un defecto abierto y una fecha.