Estrategia: qué testear en el container, en el repositorio y en la UI
Con las tres capas ya testeables, la pregunta deja de ser cómo escribir un test y pasa a ser cuál escribir y dónde ponerlo, que es una decisión de economía y no de técnica. Esta lección propone un criterio de asignación basado en dónde vive cada regla y qué maquinaria hace falta para observarla, sitúa al container como el lugar natural de la lógica de pantalla, reserva el repositorio para el contrato con los datos y deja a la interfaz solo lo que no se puede afirmar de otra forma, con un tratamiento explícito del solapamiento y de la deuda que genera duplicar la misma comprobación en dos niveles.
Saber testear un container no dice nada sobre cuántos tests escribir ni sobre dónde colocarlos, y esa segunda pregunta es la que decide si una batería de pruebas será un activo o una hipoteca. Toda organización que empieza a testear en serio atraviesa el mismo ciclo: primero no hay tests, después hay demasiados y en el sitio equivocado, y finalmente alguien descubre que la misma regla de negocio está verificada en cuatro niveles distintos y que cambiarla cuesta cuatro veces más de lo que costaría sin tests. El objetivo de esta lección no es sumar técnica sino restar trabajo: establecer un criterio de asignación que, ante cualquier comportamiento nuevo, responda sin discusión de pasillo a la pregunta de dónde se verifica y, sobre todo, dónde no.
- Aplicar un criterio de asignación basado en dónde vive la regla y qué maquinaria exige observarla.
- Delimitar lo que corresponde al container: transiciones, efectos, concurrencia y caminos infelices.
- Delimitar lo que corresponde al repositorio: contrato de datos, mapeos, caché y errores del transporte.
- Reservar la interfaz para lo que ninguna otra capa puede afirmar y calcular el coste del solapamiento.
El criterio de asignación
La regla que ordena todo lo demás cabe en una frase: un comportamiento se verifica en la capa más barata que pueda observarlo por completo. Las dos palabras cargadas son barata —en tiempo de ejecución, en fragilidad y en tiempo de escritura— y por completo, porque un test barato que solo observa una parte del comportamiento produce falsa confianza, que es peor que no tener test.
flowchart TD
P[Comportamiento nuevo] --> Q{Donde vive la regla}
Q -->|transicion o efecto| C[Test de container]
Q -->|forma o persistencia del dato| R[Test de repositorio]
Q -->|solo visible al pintar o tocar| U[Test de interfaz]
C --> B[Rapido y determinista]
R --> B
U --> L[Lento y fragil, usar poco]
style B fill:#a6e3a1,color:#11111b
style L fill:#f38ba8,color:#11111bEl criterio tiene además una lectura negativa que ahorra más trabajo que la positiva: si un comportamiento no se observa por completo en una capa, no se testea ahí ni siquiera parcialmente. Los tests parciales son la fuente principal de duplicación, porque quien los escribe sabe que no bastan, añade otro más arriba por seguridad, y termina con dos verificaciones incompletas de la misma regla que nadie se atreve a borrar porque ninguna cubre a la otra.
De ese criterio se deriva un reparto que en la práctica resulta muy estable. La inmensa mayoría de las reglas de una pantalla —qué se muestra, cuándo, en qué orden, qué pasa si falla, qué ocurre si el usuario toca dos veces— son observables por completo desde el container, sin pintar nada. Lo que no es observable ahí es la traducción del estado a píxeles y la traducción del gesto a intención, y solo eso justifica un test de interfaz.
Cuando dudes de dónde poner un test, pregúntate qué tendría que romper un compañero para que ese test fallara. Si la respuesta es una regla de pantalla, va al container. Si es un mapeo o una consulta, va al repositorio. Si la respuesta es un cambio en la disposición visual o en un gesto, va a la interfaz. Si no sabes responder, el test todavía no sabe qué verifica y escribirlo será tiempo perdido.
Lo que corresponde al container
El container es el domicilio de la lógica de pantalla, y por tanto el lugar donde debe concentrarse el grueso de la inversión. Cuatro familias de comprobación le pertenecen sin discusión.
La primera son las transiciones: para cada intención relevante, la secuencia exacta de estados, incluidos los intermedios. La segunda son los efectos: cuáles se emiten, con qué argumentos y en qué posición relativa respecto de los estados. La tercera es la concurrencia observable —cancelaciones, rebotes, tiempos de espera agotados, dobles pulsaciones— que gracias al reloj virtual se afirma sin esperas reales. Y la cuarta, la más rentable de todas y la más descuidada, son los caminos infelices: el fallo de red, la respuesta vacía, el dato corrupto, el permiso denegado.
El desequilibrio típico está entre la primera familia y la cuarta. Casi todos los proyectos tienen cubierto el camino feliz de sus pantallas principales y casi ninguno tiene cubierto qué ocurre cuando el servidor devuelve una lista vacía en la segunda página de un desplazamiento infinito. Y sin embargo la aritmética es contundente: el camino feliz lo recorre a mano cualquier persona que abra la aplicación una vez, mientras que el camino infeliz solo lo recorre un usuario real en el peor momento posible.
Container: alta densidad
Rápido, determinista y expresado en el idioma del producto. Es donde más tests debe haber y donde primero se nota una regresión de comportamiento.
Repositorio: contrato
Menos tests pero más profundos: mapeos, política de caché, traducción de errores del transporte a errores del dominio. Es la frontera con lo ajeno.
Interfaz: mínimos vitales
Pocos, lentos y frágiles por naturaleza. Reservados a lo que solo existe al pintar: navegación real, accesibilidad, recorridos de humo de extremo a extremo.
Un ejemplo hace visible la diferencia entre las dos primeras familias y la cuarta, que es donde suele estar el desequilibrio. La regla “si el usuario pulsa reintentar mientras la carga anterior sigue en curso, se cancela la anterior y solo se muestra el resultado de la última” es una regla de pantalla pura: no requiere ni red ni píxeles, se afirma con tres invocaciones y una secuencia de estados, y sin embargo casi ninguna base de código la tiene cubierta, porque reproducirla a mano en un emulador exige una coordinación que nadie tiene paciencia de repetir.
@Test
fun `el reintento cancela la carga anterior y publica solo la ultima`() = runTest {
val repo = RepoFalso(retardoMs = 500)
val vm = ListaViewModel(repo)
vm.test(this) {
expectInitialState()
containerHost.cargar()
expectState { copy(cargando = true) }
advanceTimeBy(200)
containerHost.cargar() // segunda peticion, la primera sigue viva
advanceUntilIdle()
expectState { copy(cargando = false, items = repo.ultimoLote) }
expectNoItems()
}
}
Hay una tentación que conviene nombrar para poder resistirla: testear el container comprobando que llamó al repositorio. Eso no es un test de comportamiento sino de implementación, y su síntoma característico es que rompe en todos los refactores y no rompe en ningún fallo real. Lo que se verifica no es que se pidieran los datos, sino que la pantalla acabó mostrando lo que debía cuando los datos llegaron —o cuando no llegaron—.
Lo que corresponde al repositorio y a la interfaz
Otra tentación simétrica y menos comentada es dejar el container sin tests porque “ya hay tests del reductor”. Un reductor puro es una función y se verifica como tal, pero el container es bastante más que su reductor: contiene la orquestación, las llamadas suspendidas, las guardias de concurrencia y la emisión de efectos, y ninguna de esas cuatro cosas aparece en un test de reductor. Cubrir el reductor y creer cubierta la pantalla es uno de los autoengaños más eficaces que existen, porque produce una cifra de cobertura excelente sobre la parte del código que menos falla.
El repositorio tiene su propia batería y su propio idioma. Aquí sí interesa el transporte: que un cuerpo de respuesta se mapee correctamente al modelo de dominio, que un fallo de red se traduzca al error tipado que la capa superior espera, que la política de caché devuelva primero lo local y después lo remoto, que una consulta filtre y ordene como se prometió. Como estas piezas emiten flujos, la herramienta natural es una librería de aserciones sobre flujos como turbine, que consume las emisiones una a una y falla si sobran o faltan, con la misma filosofía de secuencia completa que ya conoces del container. La simetría no es casual: un repositorio que expone datos como flujo es, desde el punto de vista de la verificación, el mismo objeto que un container —un productor de sucesiones observables— y merece por tanto la misma clase de aserción.
@Test
fun `primero emite la cache y despues la red`() = runTest {
val repo = ArticuloRepoImpl(local = LocalFalso(articuloLocal), remoto = RemotoFalso(articuloRemoto))
repo.observar(id).test {
assertEquals(articuloLocal, awaitItem())
assertEquals(articuloRemoto, awaitItem())
awaitComplete()
}
}
A la interfaz le queda lo que ninguna otra capa puede observar, y la lista es más corta de lo que suele creerse: que el estado se traduzca en los elementos correctos, que un gesto dispare la intención correcta, que la navegación real ocurra al recibir el efecto, que las etiquetas de accesibilidad existan, y un puñado muy reducido de recorridos completos que atraviesan la aplicación de punta a punta como haría una persona. Todo lo demás que se testea en la interfaz suele ser una regla de pantalla verificada dos veces.
Conviene notar que estas cuatro categorías comparten una propiedad: ninguna menciona reglas de negocio. Un test de interfaz bien escrito no comprueba que ante un fallo de red se conserven los datos locales —eso es una regla, y vive en el container— sino que, dado un estado con datos y un aviso, ambos aparecen en pantalla. La distinción se puede formular como una prueba de sustitución: si pudieras escribir el mismo test entregando el estado a mano, sin ejecutar la lógica, entonces el test es de traducción visual y pertenece a la interfaz; si necesitas ejecutar la lógica para llegar a ese estado, el test es de comportamiento y no pertenece ahí.
Cuándo el solapamiento sí se justifica
Sería deshonesto presentar la regla anterior sin sus excepciones, porque existen y son tres, todas ellas acotadas.
Antes de repasarlas conviene fijar el rasgo común que las hace admisibles: en las tres, el test duplicado verifica algo que la capa inferior no puede verificar aunque lo parezca. Cuando esa condición no se cumple, la duplicación no tiene defensa y lo honrado es borrar uno de los dos.
La primera es el recorrido de humo: uno o dos tests que atraviesan la aplicación entera desde el arranque hasta una acción significativa. Su función no es verificar reglas —ya verificadas más abajo— sino detectar que el cableado existe: que la inyección de dependencias resuelve, que la navegación está registrada, que la pantalla se compone sin excepciones. Duplican comprobaciones a propósito, y su valor está en cubrir precisamente lo que ninguna capa aislada cubre, que son las juntas.
La segunda es la regresión con nombre propio: cuando un incidente de producción demuestra que una capa mintió, añadir un test en la capa superior es legítimo aunque solape, porque documenta un fallo real y no una hipótesis. Ese test debe llevar en su nombre la referencia al incidente, para que quien lo encuentre dentro de dos años entienda por qué existe.
La tercera es la migración: durante una refactorización grande, mantener temporalmente la verificación en dos niveles es una red de seguridad razonable. Lo que la hace aceptable es su carácter transitorio, y por eso conviene dejar escrita en el propio test la condición de retirada.
Fuera de esas tres, el solapamiento sobrante conviene tratarlo como deuda y no como redundancia virtuosa. La forma práctica de saldarlo es un ejercicio incómodo pero muy revelador: recorrer los tests de interfaz uno a uno y, por cada aserción, preguntarse si existe abajo un test que la cubriría. Cuando la respuesta sea sí, borrar la aserción de arriba; cuando sea no, escribir el test de abajo y luego borrarla igualmente. El resultado típico de ese ejercicio en una base de código madura es una reducción sustancial del tiempo de la batería lenta sin pérdida alguna de garantías, y el equipo suele descubrir por el camino dos o tres reglas que nadie estaba verificando en ningún sitio.
El porcentaje de líneas ejecutadas es una métrica pobre porque no distingue entre ejecutar y verificar. Dos indicadores dicen mucho más: el tiempo total de la batería rápida —si supera lo que alguien tolera antes de subir código, dejará de ejecutarse— y la proporción de fallos de producción que, retrospectivamente, un test existente debería haber cazado. La segunda es incómoda de medir y por eso casi nadie la mide, y es la única que informa de verdad sobre dónde falta cobertura.
Verificar la misma regla en el container y en la interfaz no duplica el coste: lo multiplica. Cada cambio de esa regla obliga a tocar dos tests, uno de ellos lento; cada fallo produce dos informes que hay que correlacionar; y el test de interfaz, al ser el frágil, acaba tolerándose roto, con lo que pierdes también la señal del que funcionaba. Cuando descubras un solapamiento, la decisión correcta casi nunca es conservar ambos.
La conversación sobre testing se estanca cuando se plantea en términos morales —más tests es más responsable— en vez de económicos, que es como se plantea de verdad. Cada test que escribes es una compra con dos precios: el que pagas una vez al escribirlo y el que pagas cada día en tiempo de ejecución, en mantenimiento y, sobre todo, en resistencia al cambio, porque un test acopla el diseño actual a la promesa de que ese diseño se mantendrá. De ahí una asimetría que casi nadie contabiliza: un buen test paga rentas mientras el comportamiento siga siendo verdad, y un test mal ubicado cobra impuestos cada vez que alguien mejora el código. Nótese que ambos aparecen igual de verdes en el informe. El criterio de la capa más barata no es entonces una heurística de eficiencia sino una política de asignación de capital escaso: pone la verificación lo más cerca posible de la regla y lo más lejos posible de la infraestructura, porque la regla cambia cuando cambia el producto —lo cual es legítimo y merece romper un test— mientras que la infraestructura cambia por razones que al producto le dan igual, y romper tests por eso es puro despilfarro. La arquitectura MVI hace algo notable con ese presupuesto: al empujar prácticamente toda la lógica de pantalla a un objeto puro, observable y sin Android, traslada el grueso del gasto desde la capa más cara —la de interfaz, lenta, frágil y opaca en sus fallos— a la más barata, y con ello permite comprar mucha más verificación con el mismo dinero. Esa es, cuando se mira desde lejos, la justificación económica de toda la ceremonia de esta arquitectura: no la pureza conceptual del flujo unidireccional, sino que convierte comportamiento antes solo comprobable con los ojos en comportamiento comprobable por una máquina en milisegundos. Quien adopta MVI y sigue verificando sus reglas de pantalla a golpe de emulador ha pagado el precio entero y ha renunciado al producto.
- Cuenta tus tests por capa y calcula qué porcentaje del tiempo total de ejecución consume cada una; anota si la distribución te sorprende.
- Busca tres reglas de negocio verificadas en más de una capa y decide, con el criterio de la capa más barata, cuál test sobrevive.
- Localiza los tests de container que verifican llamadas al repositorio en vez de comportamiento y reescríbelos en términos de estados y efectos.
- Enumera los recorridos de interfaz que conservarías si solo pudieras quedarte con cinco, y justifica qué observa cada uno que ninguna otra capa observe.
- Escribe la regla de asignación de tu equipo en tres líneas, de forma que alguien recién incorporado pueda aplicarla sin preguntar.