La pirámide en Android: tres entornos y el coste de cada uno
La conversación habitual sobre pruebas se agota en qué probar y evita la decisión que de verdad determina si una suite se usa o se ignora: dónde se ejecuta cada prueba. Android ofrece tres entornos separados por tres órdenes de magnitud de coste y por tres grados muy distintos de fidelidad con el sistema real. Esta lección delimita el test local en la máquina virtual del ordenador, el test con Robolectric y el test instrumentado en dispositivo o emulador, cuantifica el precio de cada uno en tiempo de espera y en mantenimiento, deriva un criterio de asignación que no depende de preferencias sino de qué parte del framework toca el código, y explica por qué la forma de pirámide no es una convención estética sino una consecuencia de la aritmética del bucle de realimentación.
La pirámide de pruebas se dibuja en todas las presentaciones y se respeta en muy pocos proyectos, y el motivo no es pereza sino que en Android la pirámide no es una recomendación abstracta sobre tamaños de prueba: es una descripción de tres entornos de ejecución físicamente distintos, con tiempos de arranque distintos, con acceso distinto al framework y con modos de fallo distintos. Ejecutar la misma afirmación sobre un repositorio en la máquina virtual del ordenador, bajo Robolectric o dentro de un emulador produce tres resultados idénticos y tres experiencias de desarrollo incomparables: dos milisegundos, doscientos milisegundos o cuarenta segundos incluyendo la instalación del paquete. Esa diferencia no es un detalle de infraestructura, es la variable que decide si la suite se ejecuta en cada guardado o solo cuando la integración continua se queja. Esta lección trata los tres entornos como lo que son —tres máquinas— y construye el criterio para decidir en cuál vive cada prueba.
- Distinguir los tres entornos de ejecución de Android por su relación real con el framework.
- Cuantificar el coste marginal y el coste fijo de cada capa para razonar sobre el bucle de realimentación.
- Aplicar un criterio de asignación basado en qué API del sistema toca el código bajo prueba.
- Configurar el proyecto para que las tres capas convivan y compartan código de prueba.
Tres entornos de ejecución, no tres tamaños de prueba
El primer entorno es el que vive en src/test. Se ejecuta en la máquina virtual del ordenador, sin ningún dispositivo por medio, y compila contra un android.jar de escritorio cuyos métodos están vacíos: toda llamada al framework lanza una excepción con el mensaje de método no simulado. Esa aparente hostilidad es en realidad la propiedad más valiosa de la capa, porque convierte en error de ejecución cualquier dependencia oculta con la plataforma y obliga a que el código de dominio sea código de dominio. Existe una opción para que los métodos devuelvan valores por defecto en lugar de fallar, y conviene entenderla como lo que es: un parche que enmascara el diagnóstico.
El segundo entorno es Robolectric, y describirlo como un simulador es engañoso. Lo que hace es reimplementar el framework de Android dentro de la máquina virtual del ordenador mediante instrumentación del cargador de clases: sustituye las clases reales por implementaciones sombra que sí tienen comportamiento, carga los recursos compilados de verdad, resuelve los calificadores, ejecuta el bucle de mensajes principal en modo pausado y sabe crear actividades, inflar diseños y responder a intents. Se puede pedir el nivel de API bajo el que se quiere ejecutar, lo que permite verificar comportamiento dependiente de versión sin poseer un dispositivo de cada versión. El precio es un arranque no trivial por clase de prueba y una fidelidad que es alta pero no total.
El tercer entorno es la prueba instrumentada de src/androidTest. Aquí se construyen dos paquetes, el de la aplicación y el de prueba, se instalan en un dispositivo o emulador y el ejecutor de instrumentación arranca el proceso dentro del mismo tiempo de ejecución que usará el usuario. Es el único lugar donde el framework no es una imitación, donde existe el gestor de paquetes, el de ventanas, el de notificaciones y los permisos de verdad, y donde una prueba puede salir de la aplicación. También es el único donde el hardware, el reloj real y la contención de recursos entran en la ecuación.
JVM local
Sin framework y sin dispositivo. Milisegundos por prueba y coste fijo nulo. Verifica lógica pura, transformaciones, máquinas de estado y mapeo de errores.
Robolectric
Framework reimplementado en la JVM con recursos reales. Décimas de segundo. Verifica todo lo que necesita Context, recursos, ciclo de vida o incluso composición.
Instrumentado
ART real sobre dispositivo o emulador. Segundos por prueba más la instalación. Único entorno con sistema operativo auténtico y con acceso fuera de la aplicación.
Fuente compartida
Un directorio común incluido en los dos conjuntos permite escribir la prueba una vez y ejecutarla en las dos máquinas para contrastar fidelidad.
La aritmética del coste
Los números importan porque el argumento entero descansa sobre ellos. Una prueba local bien escrita tarda del orden de un milisegundo y no impone ningún coste fijo más allá de la compilación. Una prueba bajo Robolectric tarda entre decenas y centenas de milisegundos, y añade un coste de arranque por clase que domina cuando las clases son pequeñas. Una prueba instrumentada rara vez baja de un segundo y arrastra un coste fijo por ejecución —arrancar el emulador, construir e instalar los dos paquetes— que se mide en decenas de segundos y a veces en minutos.
La consecuencia práctica se ve mejor invirtiendo el cálculo. Con un presupuesto de diez segundos de espera, que es aproximadamente el umbral por encima del cual una persona deja de esperar y cambia de ventana, caben unos pocos miles de pruebas locales, unas decenas de pruebas con Robolectric y ninguna prueba instrumentada, porque el coste fijo ya se comió el presupuesto entero. Ese es el argumento real de la pirámide: no dice que las pruebas de integración sean peores, dice que hay un presupuesto de latencia por iteración y que las capas altas lo agotan.
Hay un segundo coste que rara vez se contabiliza y que a plazo pesa más que el tiempo: el coste de diagnóstico. Cuando falla una prueba local, el fallo apunta a una función. Cuando falla una prueba instrumentada, el fallo puede venir del código, del emulador, de la red del agente de integración continua, de una animación, de una notificación del sistema que robó el foco o del orden en que se ejecutaron las pruebas anteriores. El tiempo de una persona investigando un fallo ambiguo cuesta mucho más que los segundos de ejecución, y ese coste crece con la altura en la pirámide.
| Entorno | Coste marginal típico | Coste fijo | Fidelidad | Ambigüedad del fallo |
|---|---|---|---|---|
| JVM local | Un milisegundo | Ninguno | Nula respecto al sistema | Mínima |
| Robolectric | Decenas o centenas de milisegundos | Arranque por clase | Alta pero no exacta | Baja |
| Instrumentado | Segundos | Emulador e instalación | Total | Alta |
La intuición lleva a optimizar el tiempo por prueba y a ignorar el arranque. En una suite instrumentada real, la instalación de los paquetes, el arranque del proceso y la limpieza entre pruebas suelen consumir más que todas las afirmaciones juntas. Antes de intentar acelerar una prueba lenta, mide cuánto tarda la suite con una sola prueba dentro: ese número es el suelo y ninguna optimización del contenido lo baja.
flowchart TD
A[Codigo bajo prueba] --> B{Depende del framework de Android}
B -->|no| C[Test local en la JVM]
B -->|si| D{Depende del sistema real o de otra app}
D -->|no| E[Test con Robolectric]
D -->|si| F[Test instrumentado en dispositivo]
C --> G[Milisegundos y sin coste fijo]
E --> H[Decimas de segundo y arranque por clase]
F --> I[Segundos mas instalacion de dos paquetes]
style C fill:#a6e3a1,color:#11111b
style E fill:#f9e2af,color:#11111b
style F fill:#f38ba8,color:#11111bEl criterio de asignación
La regla útil no habla de unitario o de integración, porque esas palabras no significan nada operativo en este contexto. Habla de superficie de contacto con la plataforma. Si el código bajo prueba no menciona ningún tipo del framework, la prueba va a la máquina virtual local y cualquier otra decisión es una pérdida de tiempo. Si menciona Context, recursos, ciclo de vida, Bundle o incluso composición, y no necesita nada que el sistema real tenga que producir, la prueba va a Robolectric. Si necesita el gestor de permisos, otra aplicación, el cajón de notificaciones, el hardware o el propio proceso de instalación, la prueba es instrumentada y no hay alternativa.
Este criterio tiene un efecto secundario deseable y poco comentado: convierte una decisión de pruebas en presión de diseño. Cuando la regla dice que todo lo que toque el framework se paga más caro, aparece un incentivo constante para empujar la lógica hacia clases que no lo tocan. Una capa de dominio que se prueba entera en la máquina virtual local no es un logro de la suite, es la evidencia de que la arquitectura separó decisiones de efectos.
Queda la frontera incómoda, que es dónde poner lo que Robolectric sabe hacer pero con fidelidad imperfecta. La respuesta honesta es que la fidelidad importa según lo que se afirme: para verificar que un ViewModel publica el estado correcto tras un cambio de configuración, la imitación basta y sobra; para verificar que un diálogo del sistema aparece con el texto de la versión de Android instalada, no. Ante la duda, existe la técnica del conjunto de fuentes compartido: escribir la prueba una vez en un directorio incluido en las dos configuraciones y ejecutarla en ambas. Si difieren, el desacuerdo es información.
Un conjunto compartido no es un truco de organización, es un instrumento de medida. Cuando una prueba pasa bajo Robolectric y falla en dispositivo, acabas de encontrar exactamente el punto donde tu modelo mental del framework diverge de la realidad. Ese hallazgo suele valer más que la prueba misma.
Montar las tres capas en el proyecto
La configuración mínima declara las dependencias de cada conjunto de fuentes, habilita los recursos de Android en las pruebas locales para que Robolectric pueda resolverlos, y fija un ejecutor de instrumentación con orquestador para que cada prueba instrumentada corra en su propio proceso.
android {
testOptions {
unitTests {
isIncludeAndroidResources = true
isReturnDefaultValues = false
}
execution = "ANDROIDX_TEST_ORCHESTRATOR"
}
defaultConfig {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
sourceSets {
getByName("test").java.srcDir("src/sharedTest/java")
getByName("androidTest").java.srcDir("src/sharedTest/java")
}
}
dependencies {
testImplementation(libs.junit)
testImplementation(libs.robolectric)
testImplementation(libs.androidx.test.core)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.test.rules)
androidTestUtil(libs.androidx.test.orchestrator)
}
La misma prueba escrita en el directorio compartido usa solo API que existe en los dos mundos, que en la práctica significa la biblioteca androidx.test y evitar cualquier referencia directa a las clases sombra.
@RunWith(AndroidJUnit4::class)
class FormateadorDeFechaTest {
@Test
fun usaElIdiomaDeLaConfiguracion() {
val contexto = ApplicationProvider.getApplicationContext<Context>()
val texto = FormateadorDeFecha(contexto).relativo(hace = 2.hours)
assertThat(texto).isEqualTo(contexto.getString(R.string.hace_dos_horas))
}
}
El ejecutor anotado es el detalle que hace portable el fichero: en el conjunto local delega en Robolectric y en el instrumentado delega en el ejecutor del dispositivo, sin que la prueba tenga que saber dónde está.
Conviene extraer la idea de fondo, porque sobrevive a las herramientas concretas y explica por qué tantos equipos con buenas intenciones acaban con una suite que nadie ejecuta. La forma piramidal no es una afirmación sobre la calidad relativa de los tipos de prueba, ni una regla de proporciones que alguien decidió que fuera setenta, veinte y diez. Es la consecuencia inevitable de una restricción cognitiva: existe un umbral de latencia por encima del cual una persona deja de esperar el resultado y cambia de contexto, y ese umbral está en el orden de los diez segundos y no lo negocia ninguna metodología. Toda suite compite contra ese número. Una suite que lo respeta se ejecuta antes de cada cambio y por tanto informa mientras el cambio todavía está en la cabeza de quien lo escribió, que es el único momento en que la información es barata de usar. Una suite que lo supera se ejecuta al final, cuando el cambio ya está enredado con otros diez, y entonces cada fallo cuesta una arqueología. De ahí se sigue el corolario que de verdad importa y que casi nadie enuncia: el valor de una prueba no es su capacidad de detectar un defecto, sino el producto de esa capacidad por la probabilidad de que alguien la ejecute a tiempo. Una prueba instrumentada excelente que corre una vez al día durante la noche vale, en términos de defectos evitados por unidad de esfuerzo, muchísimo menos que una prueba local mediocre que corre cuarenta veces al día. Y esto reordena por completo la pregunta habitual. Deja de tener sentido preguntarse si una prueba es unitaria o de integración, porque esa taxonomía no predice nada útil; la pregunta operativa es cuántas veces al día se ejecutará esta prueba, y la respuesta la determina el entorno donde la coloques. Por eso mover una prueba de dispositivo a la máquina virtual local no es una degradación ni un atajo, es multiplicar su valor por su nueva frecuencia de ejecución. Y por eso el trabajo más rentable de todo este nivel no consiste en escribir más pruebas, sino en rediseñar el código para que las pruebas que ya existen puedan bajar de capa.
- Mide por separado el coste fijo y el coste marginal de tus tres capas: ejecuta cada conjunto con una sola prueba dentro y luego con todas, y calcula la diferencia.
- Clasifica cincuenta pruebas existentes según la superficie de framework que realmente tocan, no según dónde están hoy. Cuenta cuántas están una capa por encima de donde deberían.
- Mueve las cinco pruebas instrumentadas más lentas que no necesiten el sistema real a
Robolectricy compara el tiempo total de la suite. - Crea un directorio de fuentes compartido, escribe allí una prueba que dependa de recursos y ejecútala en los dos entornos. Documenta cualquier divergencia.
- Establece un presupuesto explícito de latencia para el bucle corto y añade una comprobación que falle cuando la suite local lo supere.