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

Turbine: afirmar sobre flows de forma legible

Un flujo no tiene valor sino historia, y esa diferencia es la que hace que las aserciones ingenuas sobre corrutinas resulten frágiles o directamente falsas. Esta lección explica por qué recolectar a mano en una lista produce tests que pasan por accidente, presenta la gramática de `turbine` como un lenguaje para hablar de emisiones —esperar la siguiente, exigir que no haya más, consumir el resto—, aborda las trampas específicas de `StateFlow` con su conflación y su valor inicial, y cierra con el patrón que separa la comprobación del estado de la comprobación de los efectos de una sola vez.

⏱ 19 min

Testear un valor es comparar; testear un flujo es narrar. Cuando el objeto bajo prueba no devuelve un resultado sino que emite una sucesión de resultados a lo largo del tiempo, la pregunta deja de ser cuánto vale y pasa a ser qué ocurrió, en qué orden y qué no ocurrió. Esa última parte —qué no ocurrió— es la que hunde a la mayoría de los tests de corrutinas escritos a mano, porque afirmar la ausencia de algo en un sistema asíncrono es afirmar sobre el futuro, y el futuro requiere esperar. turbine existe para convertir esa dificultad en gramática: en lugar de recolectar en una lista y confiar en que el tiempo haya sido suficiente, se declara una expectativa por emisión y se exige que el flujo quede exhausto. La diferencia no es de comodidad sino de validez: un test escrito con recolección manual puede pasar sin haber comprobado nada, y no hay forma de distinguirlo mirándolo.

🎯 Al terminar esta lección sabrás
  • Explicar por qué recolectar en una lista produce tests que pasan por casualidad.
  • Dominar la gramática básica: esperar emisión, exigir ausencia y consumir el resto.
  • Tratar correctamente StateFlow, su valor inicial y la conflación de emisiones intermedias.
  • Separar la comprobación del estado de la de los efectos usando dos suscripciones simultáneas.

El problema de recolectar a mano

La primera versión que casi todo el mundo escribe consiste en lanzar una corrutina que acumula en una lista, ejecutar la acción y comprobar el contenido de la lista. Parece razonable y es defectuosa por dos motivos independientes.

// Fragil: nada garantiza que la emision haya llegado antes de la asercion
val emitidos = mutableListOf<CarritoState>()
val job = launch { viewModel.state.toList(emitidos) }
viewModel.enviar(CarritoIntent.ReintentoPulsado)
assertEquals(2, emitidos.size)
job.cancel()

El primer defecto es la carrera: entre enviar el intent y evaluar la aserción no hay ninguna sincronización, de modo que el test comprueba el estado de la lista en un instante arbitrario. En un dispatcher de test puede funcionar hoy porque el planificador resuelve el trabajo pendiente antes de devolver el control, y romperse mañana en cuanto alguien introduce un salto de hilo. El test no es correcto, es afortunado.

El segundo defecto es más grave porque no produce fallos sino silencio: si el flujo emite una tercera vez después de la aserción, nadie se entera. El test afirma que hubo dos emisiones cuando en realidad afirma que había dos en el momento de mirar. La emisión de más —un estado de carga que reaparece, un efecto duplicado— es exactamente el bug que se quería cazar, y es invisible para esta forma de escribir.

⚠️
El síntoma delator es el retardo mágico

Cuando un test de flows contiene una espera arbitraria para que dé tiempo a emitir, el problema no es el número elegido: es que la aserción no sabe qué está esperando. Subirlo hace el test más lento y solo un poco menos inestable. La solución no es esperar más, es esperar a algo concreto, y eso es precisamente lo que ofrece una librería de aserciones sobre flujos.

La gramática de turbine

turbine sustituye la lista por un lenguaje de expectativas. Se abre una suscripción con test, y dentro del bloque cada llamada de espera se suspende hasta que llega la emisión correspondiente o hasta que vence un tiempo límite razonable.

@Test
fun `reintentar muestra carga y luego contenido`() = runTest {
    val viewModel = CarritoViewModel(repositorio = RepositorioFake(exito = true))

    viewModel.state.test {
        assertEquals(CarritoState(), awaitItem())

        viewModel.enviar(CarritoIntent.ReintentoPulsado)

        assertTrue(awaitItem().cargando)
        val cargado = awaitItem()
        assertFalse(cargado.cargando)
        assertEquals(3, cargado.lineas.size)

        expectNoEvents()
        cancelAndIgnoreRemainingEvents()
    }
}
⏭️

awaitItem

Se suspende hasta la siguiente emisión y la devuelve. Si no llega, el test falla por tiempo excedido en vez de por una lista vacía.

🚫

expectNoEvents

Afirma que en este instante no hay nada pendiente. Es la aserción que atrapa la emisión de más.

🧹

cancelAndIgnoreRemainingEvents

Cierra la suscripción aceptando lo que quede. Necesario en flujos infinitos como StateFlow.

💥

awaitError

Espera la terminación con excepción. Convierte el fallo esperado en una afirmación explícita.

La pieza conceptualmente decisiva es la verificación implícita al cerrar el bloque: si quedan emisiones sin consumir y no las has descartado explícitamente, el test falla. Esa regla convierte la exhaustividad en el comportamiento por defecto, que es lo contrario de lo que ocurría con la lista. Ya no hace falta acordarse de comprobar que no hubo nada más; olvidarlo es lo que produce el error.

sequenceDiagram
participant T as Test
participant B as Turbine
participant F as StateFlow
T->>B: test abre suscripcion
F-->>B: estado inicial
T->>B: awaitItem
B-->>T: estado inicial
T->>F: enviar intent
F-->>B: estado cargando
T->>B: awaitItem
B-->>T: estado cargando
T->>B: expectNoEvents
B-->>T: correcto no hay pendientes

Las trampas de StateFlow

Un StateFlow no es un flujo cualquiera y sus dos particularidades explican casi todos los tests desconcertantes de esta capa.

La primera es que siempre tiene valor: al suscribirse se recibe de inmediato el estado actual. Por eso el primer awaitItem de casi todos los tests devuelve el estado inicial y no el resultado de la acción; olvidarlo produce el clásico fallo donde se esperaba cargando y llegó el estado vacío. Conviene consumir y afirmar ese primer valor de forma explícita, porque además documenta con qué estado arranca la pantalla.

La segunda es la conflación: si dos actualizaciones ocurren más rápido de lo que el suscriptor consume, la intermedia puede desaparecer. Esto significa que afirmar la existencia de un estado de carga fugaz es, en el caso general, una aserción sobre la velocidad relativa del planificador, no sobre la lógica. Cuando el estado intermedio importa de verdad para el producto, la comprobación robusta no es sobre el flujo sino sobre el reducer, donde la transición es un valor y no un instante.

La tercera particularidad, menos comentada, es que un StateFlow no termina nunca. No hay emisión de finalización que esperar, de modo que el bloque debe cerrarse cancelando; olvidarlo deja el test colgado hasta el límite de tiempo y produce un fallo cuyo mensaje no señala la causa real.

💡
Compartir con arranque perezoso cambia el guion

Si el estado se construye con stateIn y una política que arranca al haber suscriptores, la máquina productora no está corriendo antes de que el test se suscriba. Manda entonces abrir la suscripción antes de enviar el primer intent y afirmar el valor inicial dentro del bloque; hacerlo al revés produce un test que unas veces ve la carga y otras no, según qué haya llegado antes.

Estado y efectos a la vez

En MVI conviven dos canales: el estado, que es conflado y siempre disponible, y los efectos de una sola vez, que no admiten pérdida ni repetición. Comprobarlos en el mismo bloque mezcla dos gramáticas y produce tests difíciles de leer. La forma limpia es abrir dos suscripciones anidadas.

@Test
fun `guardar con error deja el estado limpio y emite un aviso`() = runTest {
    val viewModel = CarritoViewModel(repositorio = RepositorioFake(exito = false))

    viewModel.efectos.test {
        viewModel.state.test {
            assertEquals(CarritoState(), awaitItem())
            viewModel.enviar(CarritoIntent.GuardarPulsado)
            assertTrue(awaitItem().cargando)
            assertFalse(awaitItem().cargando)
            cancelAndIgnoreRemainingEvents()
        }

        assertEquals(CarritoEfecto.MostrarAviso("Sin conexion"), awaitItem())
        expectNoEvents()
        cancelAndIgnoreRemainingEvents()
    }
}

Ese test comprueba dos propiedades que un solo canal no puede demostrar: que el estado no se quedó cargando y que el aviso se emitió exactamente una vez. La segunda es la que importa de verdad en producción, porque el fallo típico de los efectos no es la ausencia sino la duplicación tras un cambio de configuración, y solo se caza afirmando que no hay nada más.

Afirmar la ausencia es el acto que distingue un test asíncrono de un test que finge serlo

Aquí hay una asimetría lógica que conviene mirar de frente porque explica por qué esta capa de pruebas es tan traicionera. Afirmar que algo ocurrió es fácil incluso en un sistema asíncrono: basta con esperar hasta verlo, y cuando lo ves la afirmación queda establecida para siempre. Afirmar que algo no ocurrió es, en cambio, una proposición sobre todo el futuro, y ningún test puede esperar indefinidamente para establecerla. La consecuencia es que las dos afirmaciones no tienen la misma naturaleza epistémica: la primera es una verdad, la segunda es una apuesta acotada por una ventana temporal. Todo el diseño de una librería de aserciones sobre flujos consiste en hacer esa apuesta lo más honesta posible, y su decisión más inteligente no es ofrecer una comprobación de ausencia sino invertir la carga de la prueba al cerrar la suscripción. Al fallar por defecto cuando quedan emisiones sin consumir, la exhaustividad deja de depender de que alguien se acuerde de comprobarla y pasa a ser lo que ocurre si no haces nada. Esa inversión es la razón profunda por la que estos tests atrapan bugs que la recolección manual no ve, y también la razón por la que a veces molestan: te obligan a declarar la historia completa del flujo en vez de las tres emisiones que te interesaban. Esa molestia es el precio de que el test signifique algo. Y hay un corolario práctico que vale más que cualquier receta: si tu test de flujo no contiene ninguna afirmación de ausencia ni cierra exigiendo exhaustividad, entonces no está comprobando el comportamiento asíncrono en absoluto, está comprobando que en algún momento apareció el valor que esperabas, cosa que también sería cierta de un sistema que emite ese valor mil veces y luego se cae.

Una advertencia final sobre el tiempo virtual: dentro de runTest el reloj lo controla el planificador, de modo que un retardo de treinta segundos en el código de producción se resuelve al instante. Eso es una ventaja enorme, pero deja de valer si alguna parte del código salta a un dispatcher real. Cuando un test tarda de verdad lo que dice el retardo, la causa casi siempre es una dependencia que no recibió el dispatcher de test por inyección.

⚔️ Reescribe tu peor test de flujo
  1. Busca en tu proyecto un test que recolecte emisiones en una lista o que contenga una espera arbitraria y anota qué propiedad creía comprobar.
  2. Reescríbelo con suscripción explícita, consumiendo el valor inicial y afirmando cada emisión por separado.
  3. Añade una afirmación de ausencia al final y comprueba si aparece alguna emisión que no sabías que existía.
  4. Introduce a propósito una emisión duplicada en el ViewModel y verifica que la versión nueva falla mientras la vieja pasaba.
  5. Separa la comprobación de efectos en su propia suscripción anidada y demuestra que el aviso se emite exactamente una vez.