wandres.dev
TESTING · kotlin.test y corrutinas

Testear flujos sin colgar el test

Recolectar un flujo dentro de un test parece trivial hasta que el flujo no termina, y entonces el test no falla: se queda esperando para siempre. Esta lección explica por qué la terminación es la propiedad que distingue un flujo comprobable de uno que cuelga la suite, repasa las estrategias de la biblioteca estándar para acotar la recolección, presenta el modelo de Turbine —una cola de eventos que hay que consumir explícitamente y que falla si sobra algo— y desmonta la ilusión de que un StateFlow emite todo lo que se le asigna, mostrando cómo la conflación, la igualdad estructural y la política de suscripción deciden qué llega realmente al colector.

⏱ 24 min

Un flujo frío que emite tres elementos y termina se comprueba con una línea, y esa facilidad engaña. El código interesante produce flujos que no terminan nunca: estados de pantalla, sesiones abiertas, resultados de búsqueda que se reemplazan, canales de eventos. Contra uno de esos, la operación de recolección no es una función que devuelve un valor sino una suspensión perpetua, y un test que la invoca sin más no falla ni pasa, se cuelga. Todas las técnicas de esta lección son variantes de la misma respuesta: introducir en el test una condición de salida que el flujo bajo prueba no tiene, y hacerlo sin alterar el comportamiento que estás comprobando.

🎯 Al terminar esta lección sabrás
  • Explicar por qué recolectar un flujo infinito dentro de un test lo cuelga y qué condición de salida hay que introducir.
  • Aplicar las estrategias de la biblioteca estándar para acotar la recolección y conocer sus puntos ciegos.
  • Usar el modelo de cola de eventos de Turbine y entender qué garantiza su exigencia de consumo total.
  • Predecir qué emisiones de un StateFlow llegan a un colector y cuáles se pierden por conflación o por igualdad.

Por qué un test de flujo se cuelga

La función de recolección es suspendida y solo retorna cuando el flujo se completa. Con un flujo frío finito eso ocurre enseguida; con un flujo caliente no ocurre jamás, y el test se queda dentro. La primera familia de soluciones consiste en envolver el flujo en un operador que le imponga un final artificial.

// Finito: sin problema
val lista = flowOf(1, 2, 3).toList()

// Infinito acotado por número de elementos
val tres = estadoDeLaPantalla.take(3).toList()

// Infinito acotado por una condición sobre el contenido
val cargado = estadoDeLaPantalla.first { it is Estado.Cargado }

Estas herramientas resuelven muchos casos y tienen dos puntos ciegos importantes. El primero: acotar por número exige saber de antemano cuántas emisiones habrá, y si el código emite una de menos el test vuelve a colgarse, ahora con una causa más difícil de leer. El segundo, más sutil: la recolección empieza cuando ejecutas la línea, así que cualquier emisión producida por una acción anterior ya se ha perdido. El patrón de disparar la acción y después recolectar es una condición de carrera escrita en dos líneas.

La segunda familia consiste en lanzar el colector como una corrutina independiente que se suscribe primero, ejecutar la acción después y cancelar el colector al final. Es correcto, pero exige colocar a mano el ámbito, el dispatcher y la cancelación en cada test.

@Test
fun publicaLosEstadosEnOrden() = runTest {
    val vistos = mutableListOf<Estado>()
    backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
        modelo.estado.collect { vistos += it }      // suscrito antes de actuar
    }

    modelo.buscar("kotlin")
    advanceUntilIdle()

    assertEquals(listOf(Estado.Inicial, Estado.Cargando, Estado.Cargado), vistos)
}
⚠️
Suscribir antes de actuar

El orden importa siempre: primero el colector, después la acción. Con un dispatcher no confinado el colector queda suscrito dentro de la propia llamada de lanzamiento; con el estándar necesitas ceder el control antes de disparar nada.

Turbine: emisiones como aserciones consumidas

Turbine invierte el modelo. En lugar de recolectar en una lista y comprobarla al final, abre un ámbito de prueba sobre el flujo que expone una cola de eventos, y el test va extrayendo eventos uno a uno con funciones suspendidas que esperan lo justo. La aserción y la espera son la misma operación.

@Test
fun emiteCargandoYDespuesElResultado() = runTest {
    modelo.estado.test {
        assertEquals(Estado.Inicial, awaitItem())
        modelo.buscar("kotlin")
        assertEquals(Estado.Cargando, awaitItem())
        assertEquals(Estado.Cargado(listOf("Kotlin in Action")), awaitItem())
        cancelAndIgnoreRemainingEvents()
    }
}

La propiedad que hace valiosa a esta biblioteca no es la comodidad sintáctica sino una regla de higiene que impone sin pedir permiso: al cerrar el bloque, si quedan eventos sin consumir el test falla. Eso convierte una emisión de más —el bug clásico de un estado publicado dos veces, o de un Cargando que reaparece tras el resultado— en un fallo explícito, cuando con una lista y una comparación al final habría pasado desapercibido o habría producido un mensaje ilegible.

🧪

Esperar un elemento

awaitItem suspende hasta la siguiente emisión y la devuelve. Si no llega dentro del plazo configurado, falla con un mensaje que nombra el flujo.

🏁

Esperar el final

awaitComplete y awaitError afirman terminación normal o excepcional. Un flujo que debía terminar y no termina falla aquí, no en un cuelgue.

🤐

Afirmar el silencio

expectNoEvents comprueba que en este instante no hay nada pendiente. Es la aserción negativa que ninguna lista recolectada puede expresar.

🧵

Varios flujos a la vez

turbineScope junto a testIn permite abrir varias turbinas simultáneas y comprobar el entrelazado entre ellas dentro del mismo test.

flowchart TD
A[Abrir bloque de prueba sobre el flujo] --> B[Suscripcion inmediata]
B --> C[Cola de eventos]
C --> D[awaitItem consume uno]
D --> E{Quedan eventos al cerrar}
E -->|No| F[Test correcto]
E -->|Si| G[Fallo por evento no consumido]
C --> H[awaitComplete o awaitError]
H --> F

StateFlow: conflación, igualdad y suscripción

Un StateFlow no es un flujo con memoria: es un contenedor de estado observable, y esa diferencia cambia por completo qué emisiones puede ver un colector. Tres reglas la gobiernan y las tres sorprenden la primera vez.

La primera es que el valor actual se entrega siempre al suscribirse, así que el primer elemento que recibe cualquier colector es el estado en el momento de la suscripción, no la siguiente actualización. La segunda es que las emisiones están confladas: si el valor cambia dos veces antes de que el colector procese la primera, la intermedia desaparece sin dejar rastro. La tercera es que las asignaciones se filtran por igualdad estructural, de modo que asignar un valor igual al actual no produce emisión alguna.

@Test
fun laConflacionSeComeElEstadoIntermedio() = runTest {
    val estado = MutableStateFlow(0)

    estado.test {
        assertEquals(0, awaitItem())     // valor actual al suscribirse
        estado.value = 1
        estado.value = 2                 // el 1 puede no llegar nunca
        assertEquals(2, awaitItem())
        estado.value = 2                 // igual al actual: no emite
        expectNoEvents()
        cancelAndIgnoreRemainingEvents()
    }
}

De ahí se sigue una consecuencia de diseño que va más allá del test: un StateFlow es correcto para representar estado y equivocado para transportar eventos. Si tu pantalla necesita saber que hubo dos errores seguidos, y ambos producen un valor estructuralmente igual, el segundo no llegará nunca; con una data class, la igualdad que genera el compilador es precisamente lo que provoca esa pérdida.

Queda el caso del estado derivado con stateIn y una política de suscripción, muy común en modelos de vista. Con la política que mantiene la fuente activa mientras haya suscriptores, el flujo de origen no arranca hasta que alguien colecta, y además sigue vivo durante un margen tras el último suscriptor. En un test eso significa que sin colector no ocurre absolutamente nada, y que el margen se mide en tiempo virtual y por tanto debes hacerlo avanzar si quieres observar el reinicio.

💡
Comprueba el contrato, no la implementación

Afirmar la secuencia completa de estados intermedios de un flujo conflado ata el test a un detalle que la conflación no garantiza. Afirma el estado inicial, los estados que tu contrato promete que serán observables y el estado final; lo demás es ruido que hará fallar el test al primer cambio de dispatcher.

Un test de flujo no comprueba valores: comprueba que existe una secuencia observable, y la mitad de los errores de la programación reactiva consisten en creer que existe una que en realidad nadie garantiza

El error conceptual que subyace a casi todos los tests de flujo mal escritos es tratar el flujo como si fuera una lista que llega poco a poco. Bajo esa metáfora, comprobar un flujo consiste en esperar a tenerlo entero y compararlo con la lista esperada, y toda la dificultad se reduce a saber cuándo parar. Pero un flujo no es una lista diferida: es un protocolo entre un productor y un consumidor cuya semántica depende de cuatro decisiones que casi nunca se hacen explícitas —si es frío o caliente, si aplica contrapresión o descarta, si conflaciona o preserva, si repite el último valor a quien llega tarde—, y cada una de esas decisiones determina qué secuencias son observables. La conflación de un StateFlow es el ejemplo más didáctico porque destruye la metáfora de golpe: los valores uno y dos se asignaron, ambos existieron, y sin embargo no hay ninguna garantía de que un colector vea el uno. No es una carrera que se pueda evitar con más sincronización, es la definición del tipo. Cuando un test afirma que el colector vio la secuencia completa, no está comprobando el código: está comprobando el planificador que casualmente estaba activo ese día, y por eso ese test pasa con un dispatcher no confinado y falla con el estándar, o pasa en la máquina del autor y falla en integración continua. La disciplina correcta empieza por escribir en algún sitio cuál es el contrato observable de cada flujo del sistema —qué se promete entregar siempre, qué se promete entregar al menos una vez, qué se puede perder— y solo después escribir aserciones sobre lo prometido. Ese ejercicio tiene un efecto secundario que vale más que los tests: obliga a descubrir cuántos flujos del sistema estaban transportando eventos por un canal diseñado para estado, que es el origen de esa clase de fallos que solo aparecen cuando el usuario pulsa rápido, cuando la red va lenta o cuando el dispositivo va justo de recursos. La herramienta que te obliga a consumir cada evento explícitamente no es cómoda por casualidad: es cómoda porque el consumo explícito es la única forma de que la ausencia y el exceso de emisiones dejen de ser invisibles.

⚔️ Haz visible lo que se pierde
  1. Escribe un test que recolecte un flujo infinito con la operación directa y confirma que se cuelga hasta agotar el límite temporal. Anota el mensaje exacto que produce.
  2. Reescríbelo con acotación por número de elementos y provoca que el código emita una vez menos de lo esperado. Compara la calidad del diagnóstico con la del punto anterior.
  3. Convierte el mismo test al modelo de cola de eventos y añade una emisión duplicada en el código bajo prueba. Verifica que ahora falla y lee el mensaje.
  4. Construye un MutableStateFlow y asígnale mil valores en un bucle mientras un colector los recibe. Cuenta cuántos llegan con dispatcher estándar y cuántos con el no confinado, y explica la diferencia.
  5. Toma un flujo derivado con política de suscripción por presencia de colectores y comprueba qué ocurre en el test antes de suscribirse, durante el margen tras cancelar y después de que expire.