wandres.dev
ORBIT: TESTING · assertions del flujo

orbit-test: afirmar la secuencia exacta de estados

Testear un container de Orbit no consiste en comprobar el último estado sino en afirmar la sucesión completa de fotogramas que la pantalla atravesó, porque en una arquitectura unidireccional el camino es tan observable como el destino y los fallos de interfaz viven casi siempre en el camino. Esta lección explica qué hace exactamente la librería orbit-test al poner el container en modo de prueba, por qué la emisión inicial se afirma aparte, cómo expectState compara deltas en vez de estados completos, y qué clase de defectos —parpadeos, cargas que no se apagan, emisiones duplicadas— solo aparecen cuando la aserción es de secuencia y no de resultado.

⏱ 18 min

Un container de Orbit no es una función que devuelve un valor: es un productor de una sucesión ordenada de estados, y esa sucesión es la especificación observable de la pantalla. Testearlo comprobando únicamente el estado final es el equivalente a juzgar una película por su último fotograma. La lista aparece, sí, pero ¿hubo indicador de carga?, ¿se apagó?, ¿se emitió un estado vacío intermedio que la interfaz pintó durante ciento cincuenta milisegundos y que el usuario percibió como un parpadeo? Ninguna de esas preguntas la responde una aserción sobre el valor final, y todas ellas son fallos reportables. La librería orbit-test existe precisamente para hacer afirmable lo que la arquitectura ya hacía observable: la secuencia entera, en orden, sin huecos y sin sobrantes.

🎯 Al terminar esta lección sabrás
  • Entender qué sustituye orbit-test al poner un container en modo de prueba y por qué eso elimina el no determinismo.
  • Distinguir la emisión inicial del resto de la secuencia y afirmarla con expectInitialState.
  • Escribir aserciones de delta con expectState en vez de construir estados completos a mano.
  • Detectar parpadeos, cargas colgadas y emisiones duplicadas que una aserción de resultado nunca vería.

Qué significa poner el container en modo test

En producción, el container de Orbit vive sobre un scope de corrutinas real, emite hacia un StateFlow caliente que la interfaz colecciona, y ejecuta el bloque de creación cuando alguien lo observa por primera vez. Ninguna de esas tres cosas es deseable en un test: el scope real introduce concurrencia que no controlas, el flujo caliente pierde emisiones si no hay coleccionista suscrito a tiempo, y la ejecución automática del bloque de creación te roba el control sobre el punto de partida.

La librería orbit-test resuelve las tres a la vez sustituyendo el container por una implementación de prueba que registra cada emisión en una cola y las entrega bajo demanda. El cambio conceptual es importante: dejas de observar un flujo y pasas a consumir un registro. Nada se pierde por llegar tarde, porque no hay tarde.

// build.gradle.kts
testImplementation("org.orbit-mvi:orbit-test:<version>")
@Test
fun `la carga emite cargando y luego la lista`() = runTest {
    val vm = ListaViewModel(repo = RepoFalso(items = listOf(item1, item2)))

    vm.test(this) {
        expectInitialState()
        containerHost.cargar()
        expectState { copy(cargando = true) }
        expectState { copy(cargando = false, items = listOf(item1, item2)) }
    }
}

Tres detalles merecen atención. El primero es que test recibe el scope del test: la vida del container queda atada a la del propio test y no hay corrutinas huérfanas al terminar. El segundo es que dentro del bloque dispones de containerHost, que es el sujeto bajo prueba ya intervenido, y sobre él invocas las intenciones como lo haría la interfaz. El tercero, y el más fácil de olvidar, es que el bloque onCreate no se ejecuta solo: si tu pantalla carga datos al nacer, tienes que pedirlo con runOnCreate, y esa explicitud es una virtud porque separa el test del arranque del test del comportamiento.

Merece la pena detenerse en por qué la cola resuelve el problema de la carrera. En producción, entre el momento en que la interfaz se suscribe y el momento en que el container empieza a emitir hay una ventana temporal, y en esa ventana caben emisiones perdidas. Un test escrito contra el flujo real hereda esa ventana y, con ella, la intermitencia: pasa cuando el planificador arranca al coleccionista primero y falla cuando arranca al productor. Al registrar toda emisión desde la construcción del container, el arnés elimina la ventana entera en vez de intentar ganar la carrera, que es la diferencia entre resolver un problema y apostar contra él.

💡
Por qué el arranque se afirma aparte

expectInitialState no es una línea ceremonial que se copia sin pensar. Afirma que el container partió del estado que tú crees, y separa esa afirmación de las que siguen. Si el estado inicial cambia porque alguien añadió un campo con un valor por defecto distinto, quieres que falle esa línea y no la tercera aserción de la secuencia, donde el diagnóstico sería mucho más confuso.

La secuencia es la especificación

La diferencia entre afirmar el resultado y afirmar la secuencia no es de rigor sino de categoría: son tests de cosas distintas. El primero verifica una función; el segundo verifica una máquina de estados.

flowchart LR
I[Intent cargar] --> S1[Estado inicial]
S1 --> S2[cargando true]
S2 --> S3[cargando false con items]
S3 --> A[Asercion de secuencia]
style S2 fill:#f9e2af,color:#11111b
style A fill:#a6e3a1,color:#11111b

La distinción se puede afinar todavía más. Una función se especifica con una relación entre entrada y salida; una máquina de estados se especifica con un conjunto de transiciones admisibles, y afirmar solo su estado final equivale a describir un autómata mencionando únicamente sus estados de aceptación. Nadie aceptaría esa descripción en un contexto formal, y sin embargo es exactamente lo que hace la mayoría de los tests de presentación.

Considera un caso real y desagradable. El repositorio devuelve primero la caché y después la red. Una aserción de resultado pasa: al final hay lista. Una aserción de secuencia revela que entre la caché y la red se emitió un estado con items vacío y cargando a falso, porque el reductor de la segunda emisión se escribió sobre una copia del estado inicial en vez de sobre el actual. La interfaz pinta ese fotograma. El usuario ve la lista desaparecer y volver. Nadie escribe un test que lo cace, salvo que el test sea de secuencia por defecto.

🧪

Aserción de resultado

Comprueba el estado final. Barata de escribir, tolerante a refactors, y ciega a todo lo que ocurrió por el camino. Útil para un reductor puro, insuficiente para un container.

🎞️

Aserción de secuencia

Comprueba cada fotograma en orden. Detecta parpadeos, indicadores colgados y emisiones duplicadas. Más frágil ante cambios legítimos, y esa fragilidad es justamente la señal.

Hay además una razón de diseño, y no solo de detección, para escribir las aserciones así. Un test de secuencia obliga a quien lo escribe a enunciar cuántos fotogramas produce cada intención, y esa enumeración es una crítica del diseño disfrazada de test: si necesitas seis aserciones para describir lo que ocurre al pulsar un botón, el container está emitiendo demasiado, y probablemente esté reduciendo campo a campo lo que debería reducir de una vez. El número de emisiones por intención es una métrica de calidad barata y casi nadie la mira.

La objeción previsible es que un test de secuencia rompe cada vez que se toca el container. Es cierta y hay que responderla con precisión: rompe cuando cambia la sucesión de estados observables, que es exactamente lo que la interfaz percibe. Si un refactor cambia la secuencia, o bien el refactor cambió el comportamiento visible —y el test hizo su trabajo— o bien la secuencia contenía emisiones innecesarias que el refactor eliminó, y actualizar el test documenta esa mejora.

Deltas, no estados completos

La forma perezosa de afirmar una secuencia es construir el estado esperado entero en cada paso. Funciona el primer día y se pudre el segundo, porque cada campo nuevo del estado obliga a tocar todos los tests aunque ninguno tenga que ver con él. El síntoma clínico de ese estilo es una revisión de código donde un cambio de una línea en el estado viene acompañado de cuarenta líneas de tests modificadas, ninguna de ellas interesante.

// Frágil: cualquier campo nuevo rompe todos los tests
expectState(ListaState(cargando = true, items = emptyList(), error = null))

// Robusto: describe solo el cambio respecto del estado anterior
expectState { copy(cargando = true) }

La variante con lambda recibe como receptor el estado previo de la secuencia y aplica sobre él la copia que tú describes, de modo que la aserción dice literalmente lo que el paso cambió. Eso tiene dos consecuencias valiosas. La primera es de mantenimiento: los campos que el paso no toca quedan fuera del test y no lo rompen. La segunda es de legibilidad, y es la que de verdad importa: el test se lee como la especificación de la transición, no como un volcado de memoria. Quien lo lee dentro de seis meses entiende qué hace ese intent sin reconstruir mentalmente el estado completo.

Conviene una advertencia sobre el abuso de la forma con lambda. Describir el delta respecto del estado anterior es correcto mientras el estado anterior sea el que tú crees; si encadenas quince aserciones de delta, la decimoquinta habla de un estado acumulado que ya nadie tiene en la cabeza y el test se vuelve ilegible por otra vía. La regla práctica es afirmar con delta y, cada pocas transiciones o en el estado final de la secuencia, anclar con una comprobación explícita del campo que de verdad importa, para que el lector recupere el suelo.

El corolario práctico es que el test también detecta lo contrario de lo que buscas: si expectState con un delta pequeño falla porque el estado real cambió campos que tú no mencionaste, has descubierto que el intent tiene efectos sobre el estado que nadie documentó. Esa es una de las formas más baratas de encontrar acoplamiento accidental en un reductor.

⚠️
El sobrante también es un fallo

Afirmar tres estados y que el container haya emitido cuatro es un test roto, no un test aprobado. Un buen arnés de pruebas falla ante emisiones no consumidas, y cuando quieras ignorar deliberadamente la cola —porque el intent sigue trabajando y no te interesa— debes decirlo de forma explícita con una cancelación del resto de elementos. Ignorar por omisión es cómo se cuelan las emisiones duplicadas en producción.

Los tres fallos que solo caza la secuencia

Conviene catalogar los defectos que esta forma de testear encuentra, porque el catálogo justifica el método mejor que cualquier argumento general. Son tres y aparecen una y otra vez en bases de código reales.

El primero es el indicador de carga colgado. Ocurre cuando una rama de error olvida apagar la bandera y deja al usuario ante un girador eterno. Una aserción de resultado sobre el camino feliz nunca lo ve, porque ese camino sí la apaga; el test de secuencia del camino infeliz lo caza en la primera ejecución, y además señala con precisión la emisión donde falta el cambio.

El segundo es el parpadeo por reinicio de estado. Nace de un reductor que construye el estado nuevo a partir del inicial en lugar de a partir del actual, con lo que campos ya poblados vuelven a su valor por defecto durante una emisión. La interfaz pinta esa emisión, y el usuario percibe un salto que ningún registro de errores documenta porque, técnicamente, no hubo error.

El tercero es la emisión redundante: dos estados consecutivos idénticos, casi siempre por reducir sin cambiar nada. No produce un fallo visible, pero sí trabajo de recomposición inútil y, lo que es peor, enmascara la lectura de cualquier registro de estados durante una depuración. Con la cola completa, el sobrante aparece como una emisión que nadie esperaba.

// El camino infeliz es donde vive el indicador colgado
@Test
fun `un fallo apaga el indicador y publica el error`() = runTest {
    val vm = ListaViewModel(repo = RepoFalso(fallo = SinConexion))

    vm.test(this) {
        expectInitialState()
        containerHost.cargar()
        expectState { copy(cargando = true) }
        expectState { copy(cargando = false, error = Error.SinConexion) }
        expectNoItems()
    }
}

Nótese que los tres defectos comparten una propiedad: son invisibles para cualquier aserción sobre el valor final, y visibles de forma inmediata para una aserción sobre la sucesión. No se trata de que el test de secuencia sea más estricto, sino de que mira al sitio correcto.

Los tres comparten también su origen sociológico, que explica por qué sobreviven tanto tiempo en producción. Ninguno lanza una excepción, ninguno aparece en un registro de errores y ninguno tiene un informe de fallo claro asociado, porque el usuario que sufre un parpadeo rara vez lo describe con precisión suficiente para que alguien lo reproduzca. Son defectos que la organización solo detecta si tiene un mecanismo que los busque activamente, y ese mecanismo, en una arquitectura unidireccional, es literalmente esta forma de escribir tests y ninguna otra.

Testear una secuencia es testear una demostración, no un teorema

Hay una diferencia epistemológica entre verificar un valor y verificar un proceso, y toda la disciplina del testing de arquitecturas unidireccionales vive en esa grieta. Verificar un valor es comprobar un teorema: existe una respuesta correcta, la calculas, la comparas, terminas. Verificar un proceso es comprobar una demostración: no basta con que la conclusión sea cierta, hace falta que cada paso se siga del anterior por una regla admisible y que no sobre ninguno. Una interfaz de usuario es, en este sentido preciso, una demostración que el usuario lee paso a paso: ve cada estado intermedio, los interpreta como afirmaciones sobre el mundo —hay algo cargando, no hay resultados, ha ocurrido un error— y construye su modelo mental a partir de esa sucesión y no solo de su desenlace. De ahí se sigue algo que cuesta aceptar: un estado intermedio incorrecto es un fallo aunque el estado final sea correcto, porque el usuario ya leyó la afirmación falsa y ya actuó en consecuencia. El parpadeo de una lista vacía no es un defecto estético, es una mentira momentánea del sistema sobre sus propios datos. MVI hace esa cadena de afirmaciones enteramente observable, y orbit-test la hace enteramente afirmable; juntas convierten una propiedad que en otras arquitecturas solo se comprueba mirando la pantalla con los ojos en una propiedad verificable por una máquina, de forma determinista y en milisegundos. El programador que solo afirma el estado final no está escribiendo un test peor: está escribiendo un test de otra cosa, y renunciando sin saberlo a la única garantía que la arquitectura le regalaba a cambio de toda su ceremonia.

⚔️ Convierte un test de resultado en un test de secuencia
  1. Toma un test tuyo que solo compruebe el estado final de una carga y reescríbelo con expectInitialState seguido de una aserción por cada emisión.
  2. Anota cuántas emisiones esperabas y cuántas hubo realmente; si el número no coincide, explica de dónde sale la diferencia.
  3. Sustituye toda construcción de estado completo por deltas con expectState y comprueba que el test sigue fallando si rompes el reductor a propósito.
  4. Introduce deliberadamente un estado intermedio con la lista vacía y verifica que tu test lo caza; si no lo caza, tu aserción es demasiado laxa.
  5. Escribe en dos frases qué comportamiento visible garantiza ese test, sin usar el nombre de ningún campo del estado.