wandres.dev
ORBIT: TESTING · assertions del flujo

Afirmar side effects: qué se emitió y en qué orden

Los efectos de una sola vez son la parte del container que le habla al mundo exterior —navegar, avisar, vibrar, abrir un documento— y por tanto son la parte cuyo fallo el usuario sufre de forma más directa. Esta lección muestra cómo orbit-test convierte el canal de efectos en una cola afirmable, por qué el orden relativo entre estados y efectos importa tanto como su contenido, cómo se comprueba la ausencia de un efecto sin caer en la espera arbitraria, y qué revela sobre el diseño del container un efecto que resulta imposible de afirmar sin trucos.

⏱ 17 min

El estado se puede volver a mirar; un efecto, no. Si el container navega dos veces, la pila de pantallas queda rota y ninguna recomposición posterior la arregla. Si emite un aviso de error cuando la operación había ido bien, el usuario ya lo leyó. Esa asimetría —el estado converge, el efecto se consuma— es la razón de que los efectos merezcan un rigor de prueba mayor que el estado y no menor, que es justo lo contrario de lo que hacen la mayoría de las bases de código, donde el reductor tiene diez tests y el canal de efectos ninguno. La buena noticia es que orbit-test no trata al efecto como un ciudadano de segunda: lo pone en la misma cola ordenada que los estados, y con ello hace afirmable no solo qué se emitió, sino cuándo se emitió respecto de todo lo demás.

🎯 Al terminar esta lección sabrás
  • Afirmar la emisión de un efecto concreto con expectSideEffect y comprender qué compara exactamente.
  • Razonar sobre el orden relativo entre estados y efectos dentro de una misma secuencia.
  • Comprobar la ausencia de un efecto sin recurrir a esperas arbitrarias ni a tiempos de gracia.
  • Reconocer cuándo un efecto difícil de afirmar delata un problema de diseño en el container.

La cola es una sola

El error mental más común al empezar es imaginar dos canales paralelos: uno de estados y otro de efectos, cada uno con su propia cola. El arnés de pruebas no funciona así, y menos mal. Estados y efectos entran en un único registro ordenado por el momento de emisión, de modo que las aserciones se escriben en el orden real en que ocurrieron las cosas.

@Test
fun `un login correcto avisa y despues navega`() = runTest {
    val vm = LoginViewModel(auth = AuthFalso(resultado = Exito(usuario)))

    vm.test(this) {
        expectInitialState()
        containerHost.enviar(credenciales)
        expectState { copy(enviando = true) }
        expectState { copy(enviando = false) }
        expectSideEffect(LoginEfecto.Avisar("Bienvenido"))
        expectSideEffect(LoginEfecto.NavegarAInicio)
    }
}

Ese test afirma cinco cosas y solo tres son obvias. Afirma el estado inicial, afirma las dos transiciones y afirma los dos efectos, pero además afirma —de forma implícita y muy valiosa— que el aviso se emitió antes que la navegación, y que ambos ocurrieron después de que el indicador de envío se apagara. Cambiar el orden en el container rompe el test aunque el conjunto de efectos emitidos sea idéntico.

La unificación de la cola tiene una consecuencia metodológica que conviene enunciar aparte: elimina la necesidad de sincronizar dos observadores. En un arnés con dos colas separadas, cualquier afirmación sobre el orden relativo entre un estado y un efecto exige un mecanismo externo —marcas de tiempo, contadores, una lista compartida— y ese mecanismo es código de test que puede tener sus propios fallos. Con una sola cola, el orden relativo no se comprueba: se lee.

💡
Por qué la comparación por igualdad estructural lo cambia todo

Modelar los efectos como una jerarquía sellada de data class y data object hace que la comparación sea por valor y no por identidad. Eso permite escribir el efecto esperado literalmente en el test, incluidos sus argumentos, y obtener un mensaje de fallo que muestra el valor esperado junto al recibido. Un efecto modelado como una lambda o como una cadena de texto libre pierde esa propiedad y convierte cada fallo en una sesión de depuración.

El orden no es un detalle de implementación

Hay una tentación razonable de considerar el orden entre efectos como algo interno que un test no debería fijar. Es un error, y se ve con un ejemplo. Si la navegación se emite antes que el aviso, la pantalla destino ya está montada cuando llega el mensaje, y el aviso aparece encima de una pantalla que no lo contextualiza —o se pierde, si el anfitrión del mensaje pertenecía a la pantalla que acabas de abandonar—. El orden de los efectos es, literalmente, comportamiento visible.

sequenceDiagram
participant U as Usuario
participant C as Container
participant V as Vista
U->>C: intent enviar
C->>V: estado enviando true
C->>V: estado enviando false
C->>V: efecto avisar
C->>V: efecto navegar
Note over V: el orden determina donde se ve el aviso

Hay una forma útil de decidir si un orden concreto merece quedar fijado en un test: preguntarse si un observador humano notaría la diferencia al invertirlo. Si la respuesta es sí, el orden es contrato y el test debe protegerlo. Si es no, probablemente esos dos efectos sean independientes y convenga emitirlos desde ramas separadas, porque un orden que no importa pero que el test fija se convierte en fricción gratuita en el siguiente refactor.

Lo mismo vale para el orden entre estado y efecto. Emitir la navegación antes de apagar el indicador de carga deja la pantalla anterior con un girador encendido durante la transición, y si el usuario vuelve atrás se encuentra una pantalla aparentemente cargando para siempre. Ese fallo es invisible en un test que agrupe estados por un lado y efectos por otro; es evidente en uno que afirme la cola única en su orden real.

🧭

Efecto de navegación

Consecuencia irreversible dentro de la sesión. Merece aserción de contenido —el destino y sus argumentos— y de posición respecto al estado que lo precede.

💬

Efecto de mensaje

Consecuencia perceptible pero recuperable. Merece aserción de contenido y de multiplicidad: emitir dos veces el mismo aviso es un fallo tan real como no emitirlo.

Afirmar la ausencia sin esperar

La aserción más difícil no es que algo ocurra, sino que algo no ocurra. Comprobar que un login fallido no navega parece exigir esperar un rato y confiar en que nada llegue, y esa es exactamente la receta del test lento e intermitente que acaba desactivado.

El error de fondo de esa aproximación es epistemológico y no técnico: esperar y no ver nada no demuestra que nada vaya a llegar, solo que no llegó dentro de la ventana observada. Ninguna ampliación de la ventana convierte esa observación en una demostración, y por eso los tests de ausencia basados en espera son estructuralmente incapaces de dar la garantía que prometen.

La solución no es esperar mejor sino cerrar la secuencia. Como el arnés registra las emisiones en una cola determinista y el reloj es virtual, cuando el trabajo pendiente se ha agotado la cola está completa: no hay un “todavía podría llegar”. Afirmar que no queda nada por consumir es, en ese punto, una afirmación exacta y no una apuesta probabilística.

@Test
fun `un login fallido avisa del error y no navega`() = runTest {
    val vm = LoginViewModel(auth = AuthFalso(resultado = Fallo(CredencialesInvalidas)))

    vm.test(this) {
        expectInitialState()
        containerHost.enviar(credenciales)
        expectState { copy(enviando = true) }
        expectState { copy(enviando = false, error = "Credenciales invalidas") }
        expectSideEffect(LoginEfecto.Avisar("Credenciales invalidas"))
        expectNoItems()
    }
}

La última línea es la que hace el trabajo interesante: declara que la secuencia terminó ahí, y con ello convierte “no navegó” en una consecuencia demostrada y no en una esperanza. Nótese que esta aserción también protege contra el fallo inverso y más sutil: la emisión duplicada. Si un doble envío accidental produjera dos avisos idénticos, el segundo quedaría sin consumir y la comprobación de cola vacía fallaría, sin que hiciera falta escribir un test específico para ese escenario.

⚠️
Cuando el efecto no se deja afirmar, sospecha del diseño

Si para comprobar una navegación tienes que espiar un objeto de navegación real, o inspeccionar una lambda capturada, o mirar dentro de un doble de prueba del controlador, el problema no es del arnés: es que ese efecto no está modelado como efecto. Un container correcto no navega, sino que declara que hay que navegar, y la traducción de esa declaración a una llamada real ocurre en la vista. Cuando la declaración es un valor de un tipo sellado, afirmarla es comparar dos valores.

Los efectos que se emiten de más

La familia de fallos más rentable de cubrir no es la del efecto ausente sino la del efecto repetido, porque es la que el desarrollo manual detecta peor: en el emulador, con un solo toque cuidadoso, la doble emisión no se manifiesta casi nunca.

El caso canónico es el envío disparado dos veces por una doble pulsación rápida. Sin guardia, el container lanza dos operaciones, recibe dos respuestas correctas y emite dos navegaciones idénticas, con lo que la pila queda con la pantalla destino duplicada y el botón de retroceso hace algo que nadie diseñó. El test que lo cubre no necesita nada sofisticado: invoca la intención dos veces seguidas y afirma la secuencia completa.

@Test
fun `una segunda pulsacion mientras se envia no navega dos veces`() = runTest {
    val vm = LoginViewModel(auth = AuthFalso(resultado = Exito(usuario)))

    vm.test(this) {
        expectInitialState()
        containerHost.enviar(credenciales)
        containerHost.enviar(credenciales)   // doble toque
        expectState { copy(enviando = true) }
        expectState { copy(enviando = false) }
        expectSideEffect(LoginEfecto.NavegarAInicio)
        expectNoItems()
    }
}

La aserción final vuelve a ser la que sostiene el test: sin ella, la segunda navegación quedaría en la cola sin consumir y nadie se enteraría. Nótese además que este test documenta la guardia elegida —descartar la segunda pulsación mientras la bandera de envío esté encendida— sin obligar a nadie a leer el container, y que romperla en un refactor lo hace fallar de inmediato.

Conviene señalar que el test anterior verifica una guardia y no la ausencia de un problema. La diferencia importa: si mañana alguien decide que la protección contra el doble toque vive en la interfaz —deshabilitando el botón mientras se envía— este test empezará a fallar, y eso es correcto, porque la protección habrá cambiado de sitio y la decisión debe discutirse en la revisión del cambio y no colarse en silencio.

Un segundo caso, más sutil, es el efecto emitido en la reconstrucción del estado. Si el container publica un aviso dentro del bloque de creación y la interfaz vuelve a observar tras un cambio de configuración, el aviso puede reaparecer sin motivo. Ese fallo es difícil de razonar leyendo el código y trivial de afirmar con un test que llame explícitamente a la ejecución del bloque de creación y compruebe que la cola queda como debe.

Un efecto es una promesa; el test es el único notario que la firma

El estado tiene una propiedad de perdón que los efectos no tienen: si emites un estado equivocado y luego el correcto, el sistema converge y el error se disuelve en la última emisión. Un efecto no converge, porque no describe cómo son las cosas sino que ordena que algo ocurra, y una orden ejecutada no se retira emitiendo otra. Esa diferencia es la misma que separa una declaración de una acción en la filosofía del lenguaje, y tiene una consecuencia práctica que casi nadie extrae hasta que la sufre: el coste esperado de un fallo en el canal de efectos es estructuralmente mayor que el de un fallo en el estado, y sin embargo la inversión en pruebas suele ir en la dirección contraria, porque el estado es cómodo de afirmar y el efecto parece requerir un emulador. La arquitectura unidireccional resuelve esa asimetría de una manera que conviene apreciar despacio: al obligar a que el efecto sea un valor —un miembro de una jerarquía sellada, comparable por igualdad, sin comportamiento propio— convierte una acción sobre el mundo en un dato inerte que viaja por una cola. Testear una navegación deja de significar arrancar Android y pasa a significar comparar dos objetos, y esa reducción no es un truco de laboratorio: es el mismo movimiento que hace la programación funcional cuando reifica el efecto en una descripción y separa la construcción del plan de su ejecución. Aquí el plan es la cola de efectos, la ejecución es la vista, y el test se sitúa exactamente en la frontera, que es el único sitio donde se puede verificar que lo prometido y lo ordenado coinciden. Quien entiende esto deja de preguntarse si merece la pena testear efectos y empieza a preguntarse cómo pudo pasar tanto tiempo sin hacerlo.

⚔️ Somete tus efectos al mismo rigor que tu estado
  1. Cuenta cuántos tests cubren tu reductor y cuántos cubren tu canal de efectos; si la proporción te incomoda, ya tienes el trabajo de la semana.
  2. Escribe un test que afirme el orden exacto entre el último cambio de estado y el efecto de navegación, y comprueba que falla al invertirlos en el container.
  3. Añade una aserción de cola vacía a un test de camino infeliz y verifica que caza una navegación indebida introducida a propósito.
  4. Busca un efecto que no puedas afirmar sin espiar un objeto externo y refactorízalo hasta que sea un valor de tu tipo sellado.
  5. Provoca una emisión duplicada con un doble envío y explica qué aserción la detecta y por qué no hizo falta un test dedicado.