wandres.dev
TESTING DE PRESENTACIÓN · la pirámide

La pirámide en Android: qué demuestra cada estrato y cuánto cuesta

La pirámide de tests no es una metáfora decorativa sino un modelo económico sobre el coste marginal de la confianza. Esta lección la reinterpreta para una aplicación MVI en Android: por qué el reducer sostiene la base con tests que valen microsegundos, qué añade exactamente un test del ViewModel que el reducer no puede demostrar, qué preguntas solo responde un test instrumentado sobre Compose, y cómo el reparto de esfuerzo entre los tres estratos deja de ser una cuestión de gusto para convertirse en una consecuencia de dónde vive el riesgo real de cada capa.

⏱ 18 min

La pirámide de tests se cita tanto que ha perdido su contenido: se ha convertido en un dibujo que todo el mundo reconoce y casi nadie usa para decidir. Sin embargo, debajo del triángulo hay una tesis dura y comprobable, y es esta: el coste de un test y la confianza que aporta no crecen juntos, crecen en direcciones distintas, y por eso la forma óptima de una batería de pruebas no la fija la estética sino la aritmética. En una aplicación MVI el argumento se vuelve inusualmente nítido, porque la arquitectura ya ha separado la lógica en piezas con propiedades muy diferentes: una función pura que decide, un objeto con corrutinas que orquesta, y un árbol de composables que dibuja. Cada una de esas piezas admite un tipo de prueba distinto, falla por motivos distintos y cuesta un orden de magnitud distinto. Entender qué demuestra cada estrato —y sobre todo qué es incapaz de demostrar— es lo que separa una batería de tests que da confianza de una que solo da tiempo de espera.

🎯 Al terminar esta lección sabrás
  • Distinguir qué pregunta responde cada estrato: decisión, orquestación y presentación.
  • Justificar el reparto de esfuerzo por coste marginal y no por dogma o por costumbre.
  • Reconocer las dos deformaciones típicas de la pirámide en Android y su causa arquitectónica.
  • Identificar los riesgos que ningún estrato cubre y decidir conscientemente qué queda fuera.

Tres estratos, tres preguntas distintas

La confusión habitual consiste en creer que los tres niveles prueban lo mismo con distinta profundidad. No es así: prueban cosas incomparables, y por eso ninguno sustituye a otro.

El test del reducer responde a una pregunta sobre decisión: dado este estado y este intent, ¿cuál es el estado siguiente? Es una pregunta cerrada, con respuesta única, sin dimensión temporal. Se contesta con una llamada a función y una igualdad.

El test del ViewModel responde a una pregunta sobre orquestación: ante esta secuencia de intents y estas respuestas del repositorio, ¿qué sucesión de estados observa la vista y qué efectos se emiten? Aquí ya hay tiempo, concurrencia, cancelación y colaboradores; la respuesta no es un valor sino una traza.

El test de UI responde a una pregunta sobre presentación: dado este estado, ¿qué ve y qué puede tocar la persona que usa la aplicación? Es la única capa que puede detectar que un botón está tapado, que un texto no aparece o que un contenido carece de descripción semántica.

Escritas una debajo de otra, las tres formas revelan de un vistazo por qué no son intercambiables: cambia el andamiaje, cambia el tipo de aserción y cambia el orden de magnitud del coste.

// Estrato 1. Decision: una llamada y una igualdad, sin corrutinas ni dobles.
val siguiente = reducir(CarritoState(), CarritoIntent.ReintentoPulsado)
assertTrue(siguiente.cargando)

// Estrato 2. Orquestacion: dispatcher de test y aserciones sobre la traza.
viewModel.state.test {
    assertEquals(CarritoState(), awaitItem())
    viewModel.enviar(CarritoIntent.ReintentoPulsado)
    assertTrue(awaitItem().cargando)
    assertFalse(awaitItem().cargando)
}

// Estrato 3. Presentacion: arbol de semantica y gestos simulados.
composeTestRule.onNodeWithText("Reintentar").performClick()
composeTestRule.onNodeWithText("Sin conexion").assertIsDisplayed()

Repara en que el primer fragmento no menciona el ViewModel, el segundo no menciona la interfaz y el tercero no menciona el repositorio. Esa independencia no es casual: es la firma de una separación de capas que funciona. Cuando un test de un estrato necesita conocer las tres cosas a la vez, lo que ha fallado no es la técnica de prueba sino la frontera entre componentes.

🧮

Reducer: decisión

Función pura, sin runTest, sin dispatcher. Milisegundos por caso y cero fragilidad. Prueba reglas de negocio de pantalla.

🎼

ViewModel: orquestación

Corrutinas, colaboradores dobles y turbine. Decenas de milisegundos. Prueba secuencias, cancelación y efectos.

👁️

UI: presentación

composeTestRule sobre JVM o dispositivo. Cientos de milisegundos o segundos. Prueba lo que realmente se ve.

📷

Captura: apariencia

Imagen por estado comparada con una referencia. Prueba lo que ninguna aserción textual sabe describir.

Conviene fijar el criterio de pertenencia antes de escribir nada: un test pertenece al estrato más bajo capaz de demostrar la propiedad que te interesa. Comprobar en un test instrumentado que un descuento se calcula bien no es exhaustividad, es desperdicio; la misma regla se demuestra en el reducer con una fracción del coste y con más casos.

La deformación androide de la pirámide

En Android la pirámide se deforma casi siempre de una de dos maneras, y ambas son consecuencias arquitectónicas antes que culturales.

La primera deformación es el reloj de arena: mucha base, mucha punta y casi nada en medio. Suele aparecer en equipos que prueban bien las funciones puras y luego, al no saber cómo probar corrutinas, saltan directamente a la instrumentación. El resultado es que los bugs de orquestación —dos peticiones que se pisan, un estado de carga que no se apaga, un efecto emitido dos veces— se buscan en la capa más cara y más lenta, donde además son difíciles de reproducir.

La segunda deformación es el cono invertido: casi todo instrumentado. Ocurre cuando la lógica no está separada, cuando vive dentro de la Activity o del composable y no hay ninguna unidad que aislar. Aquí el problema no se arregla escribiendo más tests: se arregla moviendo la lógica. Una pirámide invertida es un síntoma de arquitectura, no de disciplina de pruebas.

Hay una tercera deformación, más difícil de ver porque el dibujo sale correcto: la pirámide redundante. Aparece cuando cada regla de negocio se comprueba tres veces, una por estrato, con la idea de que más comprobación es más seguridad. El resultado es una suite cuyo coste de mantenimiento se triplica sin que la confianza aumente, porque los tres tests fallan a la vez ante el mismo cambio y ninguno aporta información que los otros no tuvieran. La cobertura no se suma entre capas; lo que se suma es el trabajo de actualizarla.

Distinguir estas tres patologías importa porque el remedio es distinto en cada caso: el reloj de arena se cura aprendiendo a probar corrutinas, el cono invertido se cura refactorizando, y la redundancia se cura borrando tests. Confundirlas lleva a la reacción por defecto de todos los equipos ante una suite insatisfactoria, que es escribir más pruebas, y esa reacción solo mejora una de las tres situaciones.

flowchart TD
U[Tests de UI instrumentados] --> V[Tests del ViewModel]
V --> R[Tests del reducer]
R --> B[Rapidos deterministas y baratos]
U --> A[Lentos fragiles y caros]
style R fill:#a6e3a1,color:#11111b
style U fill:#f38ba8,color:#11111b

MVI corrige la deformación por construcción, no por voluntad. Al obligarte a nombrar cada gesto con un intent y a expresar cada transición en una función pura, deja en el estrato inferior una superficie enorme de lógica comprobable, y reduce el ViewModel a coordinación. La consecuencia práctica es que la pirámide sale con la forma correcta sin que nadie tenga que imponerla: simplemente hay mucho que probar barato y poco que probar caro.

El reparto de esfuerzo como decisión económica

Los porcentajes que circulan —setenta, veinte, diez— son una consecuencia, no una regla. La magnitud que importa es el coste marginal: cuánto cuesta el test número cien de cada estrato comparado con la confianza que añade.

En el reducer el coste marginal es prácticamente cero. Añadir cien casos a una tabla parametrizada no cambia el tiempo de la suite de forma perceptible ni introduce inestabilidad, porque no hay entorno que pueda variar. Por eso ahí la estrategia correcta es la exhaustividad: cubrir cada rama, cada frontera y cada combinación sospechosa.

En el ViewModel el coste marginal es moderado y la fragilidad empieza a asomar por el tiempo: hay que controlar dispatchers, cerrar suscripciones y afirmar sobre emisiones. La estrategia correcta ya no es exhaustividad sino representatividad: un caso feliz, un caso de error, un caso de cancelación o solapamiento por cada flujo asíncrono relevante.

En la UI el coste marginal es alto y crece: cada test añade segundos, depende de un emulador o de un entorno gráfico, y se rompe por motivos que nada tienen que ver con la lógica. La estrategia correcta es la del recorrido crítico: probar que la pantalla se compone para cada estado y que los gestos importantes llegan al ViewModel, y renunciar deliberadamente a lo demás.

💡
La prueba del cronómetro

Un criterio operativo que funciona mejor que cualquier porcentaje: la suite que corre en cada guardado debe terminar en menos de diez segundos, y la que corre en cada integración en menos de diez minutos. Si la primera se alarga, hay lógica probada en el estrato equivocado. Si la segunda se alarga, hay casos instrumentados que deberían ser casos del ViewModel. El reloj es un revisor más honesto que la opinión.

La diferencia se ve mejor comparando dos tests que persiguen la misma regla desde estratos distintos. Abajo, el caso es un dato de una lista; arriba, el caso es un guion completo.

// Exhaustividad barata: cada frontera es una fila mas en la tabla.
@ParameterizedTest
@MethodSource("fronterasDelDescuento")
fun `el descuento respeta sus limites`(caso: Caso) {
    assertEquals(caso.esperado, reducir(caso.inicial, caso.intent))
}

// Representatividad cara: un guion por flujo asincrono relevante, no por frontera.
@Test
fun `dos reintentos seguidos no dejan dos cargas activas`() = runTest {
    // Aqui se paga tiempo, dobles y control de dispatcher: elige pocos casos y buenos.
}

Hay un corolario incómodo. Un test lento no es simplemente un test caro: es un test que se ejecuta menos, y un test que se ejecuta menos protege menos. La velocidad no es una comodidad del desarrollo, es una variable de la cobertura efectiva. Una suite de dos horas se ejecuta una vez al día; una de treinta segundos se ejecuta cincuenta veces, y por tanto detecta la regresión cuando aún cabe en la cabeza de quien la introdujo.

Lo que ningún estrato demuestra

Sería deshonesto cerrar sin nombrar los huecos, porque el peligro de una pirámide bien construida es creer que cubre todo lo que importa.

Ninguna de las tres capas demuestra que el contrato con el servidor sea el que crees: eso lo comprueban los tests de contrato o los tests de integración contra un servidor falso, y sin ellos una suite verde convive con una aplicación rota en producción. Ninguna demuestra que la pantalla se vea bien; para eso está la captura de imagen, que es un cuarto estrato con su propia economía. Ninguna demuestra que el rendimiento sea aceptable, ni que el consumo de batería no se dispare, ni que la migración de base de datos no borre los datos de alguien.

Y hay un hueco más sutil que merece atención: la pirámide no valida el modelo, valida la implementación del modelo. Si tu estado admite representar situaciones imposibles, un reducer perfectamente probado producirá esas situaciones imposibles con total corrección. Los tests confirman que el código hace lo que el diseño dice; jamás confirman que el diseño sea correcto.

ℹ️
La cobertura mide superficie, no riesgo

El porcentaje de líneas ejecutadas es una métrica de superficie: dice cuánto código pasó por debajo del test, no cuánto comportamiento quedó afirmado. Un test que invoca una función y no comprueba nada suma cobertura igual que uno riguroso. Si quieres un número que signifique algo, mide en su lugar cuántas de tus reglas de negocio tienen al menos un caso frontera escrito, y observa cuánto más incómoda resulta esa cifra.

La forma honesta de convivir con estos huecos es hacerlos explícitos. Un documento breve por proyecto que enumere qué riesgos cubre cada estrato y cuáles quedan deliberadamente fuera convierte una omisión accidental en una decisión revisable, y evita la conversación circular que sigue a todo incidente en producción, cuando alguien pregunta cómo es posible que los tests estuvieran verdes. La respuesta suele ser que estaban verdes exactamente sobre lo que probaban.

La pirámide es un mapa del riesgo, no un ritual de cobertura

Conviene entender de dónde viene realmente la forma del triángulo, porque la explicación habitual —los tests de arriba son lentos— es cierta pero superficial. La razón profunda es que la capacidad de un test para localizar un fallo es inversamente proporcional a la cantidad de código que atraviesa. Cuando un test del reducer falla, el conjunto de causas posibles es una rama de un when, y el diagnóstico dura segundos. Cuando falla un test instrumentado, el conjunto de causas posibles incluye la lógica, la orquestación, la composición, la semántica, el emulador, la animación y el reloj del sistema, y el diagnóstico puede durar una tarde. Un test no vale por lo que cubre sino por lo que descarta cuando falla; los tests de arriba cubren mucho y descartan poco, y esa es la asimetría que da forma a la pirámide. De ahí se sigue algo que rara vez se dice en voz alta: el número que hay que optimizar no es el porcentaje de líneas cubiertas sino el tiempo medio entre introducir un defecto y saber exactamente dónde está. Una suite con cobertura del noventa por ciento concentrada arriba puede tener un tiempo de diagnóstico peor que otra con cobertura del sesenta bien repartida, porque la primera te dice que algo se rompió y la segunda te dice qué. Y de ahí se sigue también la razón última por la que MVI y las pruebas se llevan tan bien: la arquitectura no hace los tests más fáciles de escribir, hace los fallos más fáciles de acorralar, porque ha empujado la mayor parte de las decisiones hacia una capa donde cada fallo tiene una única causa posible. La pirámide, entonces, no es una cuota que cumplir sino la sombra que proyecta una arquitectura bien separada cuando se la ilumina desde el lado de la comprobación.

Queda una última recomendación de higiene: escribe primero el test en el estrato más bajo que puedas y solo sube cuando choques con un muro real. La tentación contraria —empezar arriba porque parece que prueba más— produce baterías caras, lentas y sorprendentemente incapaces de decirte qué has roto.

⚔️ Audita la forma de tu pirámide
  1. Cuenta los tests de tu módulo de presentación separados en tres cubos: función pura, ViewModel e instrumentados, y anota el tiempo total de cada cubo.
  2. Dibuja la figura resultante y decide si es pirámide, reloj de arena o cono invertido; si no es pirámide, escribe la causa arquitectónica, no la cultural.
  3. Elige tres tests instrumentados y comprueba si la propiedad que verifican podría demostrarse en el reducer; baja los que puedan bajar y mide el ahorro.
  4. Mide el tiempo de la suite rápida y fija un presupuesto explícito en segundos que el equipo se comprometa a respetar.
  5. Enumera por escrito tres riesgos reales de tu aplicación que ninguna de las capas actuales cubre y decide, para cada uno, si lo aceptas o lo atacas.