wandres.dev
TESTING DEL ESTADO · lógica sin UI

Testear lo reactivo: efectos, suscripciones y limpieza

Las cuatro lecciones anteriores testean funciones puras, donde todo lo que ocurre se ve en el valor devuelto. Los efectos rompen esa comodidad de raíz: no devuelven nada, ocurren en un momento que no controlas y su corrección no está en lo que producen sino en lo que hacen y, sobre todo, en lo que dejan de hacer cuando les toca callarse. Esta lección aborda el testing de la mitad impura del sistema reactivo. Empieza por el problema de observabilidad —cómo se afirma algo sobre una acción que no retorna un valor— y presenta las tres técnicas de instrumentación disponibles: el registro de llamadas, el diario de eventos y el conteo de suscripciones vivas. Sigue con la domesticación del tiempo, porque casi toda la intermitencia de una suite reactiva viene de esperar con temporizadores reales en lugar de avanzar un reloj falso y vaciar explícitamente las colas de microtareas y de efectos programados. Y culmina en la comprobación que casi nadie escribe y que es la que evita las peores regresiones: la del contrato de limpieza, verificando que al desmontar un componente o cancelar un cálculo derivado no queda ningún suscriptor colgado, ningún temporizador vivo ni ninguna arista huérfana en el grafo de dependencias. Cierra el nivel devolviendo el foco a la frontera entre núcleo y cáscara.

⏱ 22 min

Todo lo anterior descansaba sobre una comodidad que ahora se acaba: una función pura entrega su resultado y con él entrega todo lo que hizo, de modo que comparar el valor devuelto agota la comprobación. Un efecto no funciona así. No devuelve nada, o devuelve una función de limpieza que tampoco es el resultado sino una promesa sobre el futuro. Su corrección no vive en un valor sino en tres preguntas distintas y sorprendentemente independientes: si ocurrió, cuántas veces ocurrió y si dejó de ocurrir cuando debía. Las dos primeras se testean con instrumentación y suelen escribirse; la tercera es la que casi nadie escribe, y es exactamente la que separa una aplicación que se degrada tras veinte minutos de uso de una que no. Una suscripción que sobrevive al componente que la creó no rompe nada de forma visible: sigue recibiendo notificaciones, sigue recalculando, sigue reteniendo en memoria un estado que ya nadie mira, y solo se manifiesta como una lentitud creciente que nadie sabe atribuir y como un error absurdo el día que intenta pintar sobre algo que ya no existe. Esta lección cierra el nivel enseñando a testear esa mitad impura del sistema: hacer observable lo que no retorna, hacer determinista lo que ocurre en el tiempo, y convertir la limpieza en una aserción explícita en lugar de una esperanza.

🎯 Al terminar esta lección sabrás
  • Hacer observable un efecto que no devuelve valor mediante registro de llamadas, diario de eventos o conteo de suscripciones vivas.
  • Eliminar la intermitencia sustituyendo esperas reales por relojes falsos y por el vaciado explícito de las colas de efectos.
  • Escribir la aserción de limpieza que comprueba que tras desmontar no queda ningún suscriptor, temporizador ni arista viva.
  • Reconocer las fugas típicas del grafo reactivo y situar la frontera entre lo que se testea puro y lo que exige instrumentación.

Hacer observable lo que no devuelve nada

El primer obstáculo es de observabilidad pura. Si un efecto no retorna, no hay nada que comparar, así que la única salida es instrumentar: colocar en el camino del efecto algo que registre su paso y afirmar después sobre ese registro. Hay tres formas de hacerlo y elegir mal es la causa habitual de tests reactivos frágiles.

📝

Registro de llamadas

Sustituir la dependencia del efecto por una función espía que anota sus argumentos. Sirve para comprobar que el efecto se disparó con los datos correctos, pero es el más acoplado a la implementación: si mañana el efecto llama a otra función equivalente, el test falla sin que nada esté roto.

📜

Diario de eventos

Hacer que el efecto empuje una descripción de lo ocurrido a una lista y afirmar sobre la lista entera al final. Captura el orden y el número de ejecuciones, que es información que el espía por argumentos pierde, y se lee como una narración del episodio.

🔢

Conteo de suscripciones

Exponer cuántos suscriptores hay vivos en un momento dado. No mira lo que el efecto hace sino cuántos efectos existen, y es la única de las tres que puede detectar una fuga. Es la más barata y la que menos se usa.

Antes de elegir entre las tres conviene aplicar un filtro previo que ahorra la mitad del trabajo: preguntarse si lo que se quiere comprobar es de verdad el efecto o es la decisión que lo precede. Muchísimos tests de efectos existen solo porque alguien dejó una condición de negocio dentro del cuerpo del efecto. Si esa condición se extrae a una función pura que decide y devuelve una intención, la parte interesante vuelve al territorio barato de las cuatro lecciones anteriores y el efecto queda reducido a un ejecutor sin criterio, cuyo test es trivial. La regla es que el efecto debe ser tan tonto que testearlo aporte poco; cuando testear un efecto parece importante, suele ser porque contiene algo que no le pertenece.

El diario merece preferencia sobre el espía siempre que se pueda, porque afirma sobre la trayectoria y no sobre un instante. La pregunta más importante de un sistema reactivo casi nunca es si el efecto se ejecutó, sino cuántas veces se ejecutó: un efecto que corre tres veces donde debía correr una no falla ningún test de existencia y sin embargo triplica las peticiones al servidor, produce parpadeos y desordena respuestas.

test('el efecto corre una vez por cambio real, no por cada notificacion', () => {
  const diario: string[] = []
  const usuario = signal({ id: 1, nombre: 'Ada' })

  const parar = effect(() => {
    diario.push(`render:${usuario.get().id}`)
  })

  usuario.set({ id: 1, nombre: 'Ada Lovelace' }) // cambia nombre, no id
  usuario.set({ id: 2, nombre: 'Grace' })

  expect(diario).toEqual(['render:1', 'render:1', 'render:2'])
  parar()
})

El test anterior documenta con precisión quirúrgica una decisión de diseño que casi siempre queda implícita: el efecto se reejecuta al cambiar el nombre aunque solo lea el identificador, porque la dependencia se registró sobre el objeto entero y no sobre el campo. Si esa reejecución es indeseable, el diario acaba de convertir un problema de rendimiento invisible en una aserción concreta que alguien puede decidir arreglar.

Domesticar el tiempo

La segunda fuente de dolor es el tiempo. Una suite reactiva que espera de verdad —temporizadores reales, esperas por sondeo, pausas fijas de cien milisegundos— es lenta e intermitente, y la intermitencia es peor que la lentitud porque destruye la confianza en la señal. La solución no es esperar más, sino no esperar en absoluto: sustituir el reloj por uno falso que se avanza a mano y vaciar de forma explícita las colas donde el sistema encola trabajo diferido.

La distinción decisiva es entre esperar y avanzar. Esperar significa ceder el control al planificador real y confiar en que el trabajo pendiente se complete antes de la aserción, lo cual introduce una carrera entre el test y el sistema que se resuelve de forma distinta según la carga de la máquina; de ahí que estos tests fallen en la integración continua y pasen en el portátil. Avanzar significa mover el reloj de forma explícita hasta un instante elegido y ejecutar de forma síncrona todo lo que estuviera programado hasta él. Con la segunda estrategia el test no es más rápido por casualidad: es rápido y determinista porque nada queda librado al azar del planificador.

Hay que distinguir con cuidado tres colas distintas que suelen confundirse en una sola idea vaga de asincronía. La cola de macrotareas contiene los temporizadores y se avanza con el reloj falso. La cola de microtareas contiene las continuaciones de las promesas y se vacía cediendo el control una vez. Y la cola propia del sistema reactivo contiene los efectos programados por el batching, y se vacía con la primitiva que la librería exponga a tal efecto. Un test que solo avanza el reloj y olvida las microtareas queda a medio camino y falla de forma aleatoria según el orden de planificación.

test('el guardado diferido se dispara una sola vez tras la rafaga', async () => {
  vi.useFakeTimers()
  const guardar = vi.fn()
  const borrador = signal('')
  const parar = effect(() => guardarConRetardo(borrador.get(), guardar, 300))

  borrador.set('h')
  borrador.set('ho')
  borrador.set('hol')
  borrador.set('hola')

  await vi.advanceTimersByTimeAsync(299)
  expect(guardar).not.toHaveBeenCalled() // aun dentro de la ventana

  await vi.advanceTimersByTimeAsync(1)
  expect(guardar).toHaveBeenCalledTimes(1)
  expect(guardar).toHaveBeenCalledWith('hola')

  parar()
  vi.useRealTimers()
})

Repara en la aserción negativa antes del límite. Comprobar que algo todavía no ha ocurrido en el milisegundo anterior al umbral es lo que distingue un test de retardo diferido honesto de uno que pasaría igual sin ninguna lógica de retardo. Sin esa línea, el test solo dice que la función acaba llamándose, que es lo que haría también una implementación sin ventana ninguna.

💡
Restaura el reloj en el desmontaje, no al final del archivo

El reloj falso es estado global del proceso de test, así que dejarlo instalado contamina los archivos que se ejecuten después en el mismo trabajador y produce fallos que aparecen y desaparecen según el orden de ejecución. Restáuralo siempre en el gancho de limpieza del propio caso o del bloque, nunca en la última línea del test, porque si una aserción falla antes esa línea no llega a ejecutarse y el reloj se queda instalado. La misma regla vale para cualquier doble global: la restauración pertenece al desmontaje, no al camino feliz.

El contrato de limpieza y la caza de fugas

Llegamos a la comprobación que da nombre al oficio y que casi ninguna suite incluye. Un efecto reactivo establece un contrato de dos direcciones: al crearse se suscribe a las fuentes que lee, y al destruirse debe darse de baja de todas ellas, cancelar sus temporizadores pendientes, abortar sus peticiones en vuelo y soltar sus referencias. La primera mitad se prueba sola porque si falla nada funciona. La segunda mitad no se prueba nunca porque si falla todo parece funcionar, y ese es exactamente el motivo por el que las fugas llegan a producción.

La técnica es sencilla en cuanto se acepta que hay que exponer un contador. Se mide el número de suscriptores vivos antes de crear el efecto, se crea, se comprueba que subió, se destruye y se comprueba que volvió exactamente al valor inicial. Ese ida y vuelta sobre un número entero detecta la inmensa mayoría de las fugas reales, incluidas las sutiles: la suscripción anidada que se registra dentro de otro efecto y no se cancela con él, la que se recrea en cada ejecución sin liberar la anterior, la que queda colgada de un valor derivado que ya nadie observa.

flowchart TD
A[Contar suscriptores vivos: base] --> B[Crear efecto o montar componente]
B --> C[Contar de nuevo: debe subir]
C --> D[Ejecutar la interaccion bajo prueba]
D --> E[Destruir efecto o desmontar]
E --> F[Contar por tercera vez]
F -->|vuelve a la base| OK[Sin fuga]
F -->|queda por encima| FUGA[Suscripcion huerfana]
FUGA --> G[Temporizador vivo, peticion sin abortar o arista sin borrar]
style A fill:#89b4fa,color:#11111b
style OK fill:#a6e3a1,color:#11111b
style FUGA fill:#f38ba8,color:#11111b
test('desmontar deja el grafo exactamente como estaba', () => {
  const base = contarSuscriptores(fuente)

  const parar = effect(() => derivado(fuente.get()))
  expect(contarSuscriptores(fuente)).toBe(base + 1)

  fuente.set(2)
  fuente.set(3) // que la reejecucion no acumule suscripciones

  parar()
  expect(contarSuscriptores(fuente)).toBe(base)
  expect(vi.getTimerCount()).toBe(0)
})

El mismo esquema se aplica sin cambios a un componente completo: se cuenta antes de montar, se monta, se interactúa, se desmonta y se vuelve a contar. Y admite una generalización que conviene adoptar como norma de equipo: en lugar de escribir la comprobación caso a caso, se instala en el gancho de limpieza global una aserción que verifique que al terminar cada test no quedan temporizadores vivos ni suscriptores por encima de la línea base. Esa única regla, escrita una vez, convierte toda la suite existente en un detector de fugas sin tocar ni un test.

La penúltima línea del ejemplo cubre el error más frecuente de todos: el efecto que en cada reejecución vuelve a suscribirse sin haber soltado la suscripción anterior. Sin las dos escrituras intermedias, el test pasaría igualmente con esa implementación defectuosa, porque el efecto solo se habría ejecutado una vez. Provocar varias reejecuciones antes de destruir es lo que convierte la aserción en una prueba de verdad.

Lo que no se puede afirmar sobre un valor, se afirma sobre un contador

Aquí se completa el arco entero del nivel, y conviene enunciarlo con claridad porque es una sola idea aplicada cinco veces. Empezamos separando el estado de la vista para que la lógica fuese invocable; luego comparamos estados devueltos, después leyes universales sobre esos estados, después trayectorias contra un modelo de referencia. Los cuatro pasos compartían un supuesto: que la corrección era observable en un valor. Los efectos parecen quedar fuera de ese esquema porque no producen valores, y de ahí viene la creencia extendida de que lo reactivo es intestable y hay que confiar en que el framework lo haga bien. Esa creencia es falsa, y su refutación es una de las lecciones más transferibles de todo el track: cuando no puedes afirmar sobre lo que una operación devuelve, afirma sobre lo que deja detrás. Un efecto no retorna un estado, pero modifica el tamaño del grafo de dependencias, el número de temporizadores vivos, la longitud de un diario de ejecuciones, la cuenta de peticiones en vuelo. Todas esas magnitudes son números enteros, perfectamente comparables, perfectamente deterministas si has domesticado el tiempo, y sobre ellas se puede volver a hacer exactamente lo mismo que hacías con los valores puros: tablas de casos, invariantes universales, comparación contra un modelo. La invariante más importante de un sistema reactivo, de hecho, cabe en una frase que ya sabes escribir como propiedad: tras cualquier secuencia de creación y destrucción de suscriptores, el número de suscriptores vivos vuelve al que había al principio. Esa frase es una ley de conservación, se comprueba con un entero y protege a tu aplicación de la clase de degradación que ningún usuario reporta como error porque no sabe describirla. Y aquí está la moraleja que cierra el nivel: la testabilidad nunca fue una cuestión de pureza, sino de observabilidad. La pureza es solo el caso fácil, aquel en el que la observabilidad viene regalada en el valor de retorno. Cuando no viene regalada, hay que fabricarla, y fabricarla cuesta una función que devuelva un número. Un contador expuesto es infinitamente más barato que una tarde de depuración de una fuga de memoria, y es, casi siempre, la única diferencia entre un sistema reactivo del que puedes fiarte y uno que simplemente todavía no te ha fallado.

⚔️ Cierra el nivel cazando una fuga real
  1. Elige un efecto de tu aplicación que se suscriba a algo y escribe su diario de ejecuciones, afirmando el número exacto de veces que corre ante una ráfaga de tres cambios.
  2. Sustituye todas las esperas reales de esa zona de tu suite por un reloj falso y comprueba cuánto baja el tiempo total de ejecución.
  3. Añade la aserción negativa que falta: comprueba que el efecto diferido todavía no se ha disparado un milisegundo antes de su umbral.
  4. Expón un contador de suscriptores vivos en tu fuente reactiva y escribe el test de ida y vuelta que comprueba que desmontar devuelve el contador a su valor inicial.
  5. Provoca varias reejecuciones del efecto antes de destruirlo y verifica que el contador no crece con cada una. Si crece, acabas de encontrar una fuga real.
  6. Escribe la misma comprobación como propiedad universal con la técnica de la tercera lección: para cualquier secuencia de montajes y desmontajes, el número de suscriptores vivos al final es cero.