wandres.dev
TESTING DE MÁQUINAS · model-based testing

Testear los actores invocados: sustituir la promesa y gobernar el flujo

Un `invoke` mete el mundo exterior dentro de la máquina, y con él la latencia, el fallo y la indeterminación. Esta lección muestra la técnica canónica para recuperarlos: registrar la lógica del actor por nombre en `setup`, sustituirla en el test con `machine.provide`, y usar una promesa diferida cuya resolución controla el propio test para poder afirmar sobre el estado intermedio de carga, sobre el `input` que recibió el doble, sobre el camino de error con `onError` y sobre la limpieza al cancelar un actor de larga vida.

⏱ 19 min

Todo lo que hemos verificado hasta aquí vivía dentro de la máquina, donde el determinismo es gratis. El invoke rompe esa frontera a propósito: introduce una petición de red, una consulta a disco o un socket, es decir, precisamente aquello que tarda lo que quiere, falla cuando quiere y a veces no ocurre. La reacción instintiva es rendirse y probar solo el camino feliz con una espera generosa. La reacción correcta es recordar que el actor invocado se referencia por nombre y que ese nombre es un punto de sustitución de primera clase: la máquina nunca conoce la implementación, solo la etiqueta. Si en producción la etiqueta resuelve a una promesa real y en el test resuelve a una promesa cuya resolución la decides tú, entonces la latencia, el fallo y el orden dejan de ser accidentes del entorno y se convierten en tres líneas de tu escenario.

🎯 Al terminar esta lección sabrás
  • Sustituir la lógica de un actor invocado con machine.provide sin tocar la definición.
  • Controlar el instante de resolución con una promesa diferida para afirmar el estado intermedio.
  • Verificar el input que la máquina entregó al actor y el output que devolvió.
  • Ejercitar el camino de onError, la cancelación al salir del estado y la limpieza de un fromCallback.

Sustituir por nombre, no parchear módulos

La disciplina empieza en la definición de la máquina: la lógica del actor se registra en setup bajo actors y el invoke la referencia con una cadena en src. Esa indirección es lo que hace testable el efecto, y renunciar a ella escribiendo la lógica en línea condena el test a interceptar el módulo global, con todo el acoplamiento y la fragilidad que eso arrastra.

import { setup, fromPromise, fromCallback, createActor, waitFor } from 'xstate'

export const perfil = setup({
  types: { context: {} as { id: string; usuario: Usuario | null; error: unknown } },
  actors: {
    cargarUsuario: fromPromise(async ({ input }: { input: { id: string } }) => {
      const res = await fetch(`/api/users/${input.id}`)
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      return (await res.json()) as Usuario
    }),
  },
}).createMachine({ /* estados inactivo, cargando, exito, fallo */ })

En el test, provide devuelve una máquina nueva con la misma topología y otra implementación. No muta la original, así que dos tests pueden usar dobles distintos sin interferirse, y la máquina de producción sigue intacta.

const conDoble = perfil.provide({
  actors: { cargarUsuario: fromPromise(async () => ({ id: '7', nombre: 'Ana' })) },
})
💡
Sustituir tambien guards, acciones y delays

provide acepta los cuatro diccionarios que setup declara: actors, guards, actions y delays. Eso permite forzar una rama condicional devolviendo true en un guard, espiar un efecto sustituyendo una acción por una que acumula llamadas, o poner a cero un retardo que solo estorba. La regla es la misma en los cuatro casos: sustituye lo que impide observar el comportamiento, jamás lo que es el comportamiento que quieres verificar.

Una promesa cuyo reloj lleva el test

Un doble que resuelve inmediatamente prueba menos de lo que parece: la máquina pasa por cargando tan deprisa que ningún test puede afirmar nada sobre ese estado, y el indicador de carga —que es el motivo por el que existe el estado— queda sin verificar para siempre. La solución es la promesa diferida: se crea sin resolver y el test decide cuándo y cómo termina.

function diferida<T>() {
  let resolver!: (valor: T) => void
  let rechazar!: (error: unknown) => void
  const promesa = new Promise<T>((res, rej) => { resolver = res; rechazar = rej })
  return { promesa, resolver, rechazar }
}

test('la carga expone el estado intermedio y luego el resultado', async () => {
  const d = diferida<Usuario>()
  const actor = createActor(
    perfil.provide({ actors: { cargarUsuario: fromPromise(() => d.promesa) } }),
  ).start()

  actor.send({ type: 'CARGAR', id: '7' })
  expect(actor.getSnapshot().matches('cargando')).toBe(true)   // ventana observable

  d.resolver({ id: '7', nombre: 'Ana' })
  const fin = await waitFor(actor, (s) => s.matches('exito'))
  expect(fin.context.usuario?.nombre).toBe('Ana')
  actor.stop()
})

El valor de esta técnica va más allá de comprobar el indicador. Como la resolución es un acto explícito del test, puedes intercalar entre el envío y la resolución todo lo que quieras: enviar un segundo CARGAR para verificar que la petición vieja se descarta, mandar CANCELAR para comprobar que salir del estado detiene el actor, o resolver dos promesas en orden inverso al de creación para exponer una condición de carrera que en el entorno real aparecería una vez cada mil ejecuciones y jamás en una prueba manual.

Afirmar el contrato de ida

onDone y onError verifican la vuelta, pero la mitad de los defectos de integración viven en la ida: la máquina calcula mal el input y entrega un identificador equivocado, o el input es correcto pero se recalcula sobre un context obsoleto. Un doble que registra lo que recibe convierte esa ida en algo afirmable.

const entradas: unknown[] = []
const conEspia = perfil.provide({
  actors: {
    cargarUsuario: fromPromise(async ({ input }) => {
      entradas.push(input)
      return { id: '7', nombre: 'Ana' }
    }),
  },
})
const actor = createActor(conEspia).start()
actor.send({ type: 'CARGAR', id: '7' })
await waitFor(actor, (s) => s.matches('exito'))
expect(entradas).toEqual([{ id: '7' }])
Qué se verifica Herramienta Defecto que atrapa
El input calculado Doble que acumula lo recibido Identificador equivocado o context obsoleto
El estado intermedio Promesa diferida sin resolver Indicador de carga que nunca se muestra
El output aplicado Aserción sobre el context tras onDone assign que guarda mal el resultado
El camino de error Rechazo explícito de la diferida onError ausente o que pierde el motivo

Errores, cancelación y actores de larga vida

El camino de fallo se ejercita rechazando la diferida, y merece aserciones tan detalladas como el camino feliz: no basta con llegar a fallo, hay que comprobar que el motivo se conservó y que la transición de reintento vuelve a crear un actor nuevo en lugar de reutilizar el anterior.

d.rechazar(new Error('HTTP 500'))
const roto = await waitFor(actor, (s) => s.matches('fallo'))
expect((roto.context.error as Error).message).toBe('HTTP 500')

Para los actores que emiten a lo largo del tiempo, el doble natural es fromCallback: te da sendBack para inyectar eventos cuando quieras y una función de limpieza que puedes instrumentar para verificar que la cancelación ocurre de verdad al abandonar el estado. Esa comprobación es la que cierra el círculo abierto en el nivel de invoke: la promesa de que el efecto muere con su estado deja de ser confianza y pasa a ser una aserción.

const limpiezas: string[] = []
const conSondeo = tablero.provide({
  actors: {
    sondeo: fromCallback(({ sendBack }) => {
      sendBack({ type: 'TIC', n: 1 })
      return () => limpiezas.push('sondeo')
    }),
  },
})
const actor = createActor(conSondeo).start()
actor.send({ type: 'OBSERVAR' })
actor.send({ type: 'PARAR' })          // sale del estado que invoca
expect(limpiezas).toEqual(['sondeo'])
🎛️

fromPromise diferida

Para peticiones puntuales. El test decide el instante y el desenlace, y con ello gobierna el estado intermedio y las carreras.

📡

fromCallback instrumentado

Para sockets, sondeos y suscripciones. sendBack inyecta eventos a voluntad y la limpieza se convierte en algo verificable.

🧬

Máquina hija real

Si el hijo es otra máquina tuya, no la sustituyas: pruébala aparte y déjala correr entera dentro del test del padre.

sequenceDiagram
participant T as test
participant M as maquina
participant D as doble diferido
T->>M: send CARGAR con id 7
M->>D: invoke con input id 7
T->>M: getSnapshot dice cargando
T->>D: resolver o rechazar a voluntad
D-->>M: onDone u onError
T->>M: waitFor y afirma el context
Sustituir el actor no es falsear el mundo: es declarar cual de sus futuros estas demostrando

Existe una objeción recurrente contra los dobles de prueba que conviene tomarse en serio antes de descartarla: si sustituyes la petición real, ya no estás probando el sistema real. La objeción confunde dos preguntas distintas que exigen instrumentos distintos, y separarlas es lo que ordena toda la estrategia. La primera pregunta es si tu servidor responde lo que dice responder; se contesta con pruebas de contrato o de integración contra un servicio de verdad, y no hay doble que la sustituya. La segunda es si tu lógica reacciona correctamente a cada respuesta posible, y esa no solo admite un doble sino que lo exige, porque el servidor real es incapaz de fallar a demanda, de tardar exactamente lo que quieres o de resolver dos peticiones en orden invertido. Un doble bien construido no reduce el realismo del test: amplía el conjunto de mundos que puedes visitar, y lo hace de forma que cada visita queda registrada como una hipótesis explícita. Ahí está el desplazamiento conceptual que da sentido a esta lección entera. Un test con un actor sustituido no afirma mi aplicación funciona, sino algo mucho más preciso y mucho más útil: dado un mundo en el que la carga tarda y luego devuelve esto, mi máquina recorre exactamente estos estados y termina con este contexto. La promesa diferida es lo que convierte la latencia —normalmente una molestia que se sortea con esperas— en un eje del escenario que puedes recorrer paso a paso, y con ella se vuelven verificables comportamientos que casi ningún equipo prueba: la carrera entre dos peticiones concurrentes, el reintento tras la cancelación, la respuesta que llega después de que el usuario se haya ido. Todos ellos son defectos caros, intermitentes en producción e imposibles de reproducir a mano, y todos ellos se convierten en tests deterministas de veinte líneas en cuanto aceptas que el instante de resolución es un parámetro y no un hecho. El corolario cierra el nivel entero y también el arco que empezó con invoke: una arquitectura en la que los efectos se declaran por nombre y se resuelven por inyección es, simultáneamente, una arquitectura testable, una arquitectura portable entre entornos y una arquitectura cuyas dependencias son legibles de un vistazo. La testabilidad nunca fue el objetivo; fue la señal de que las fronteras estaban trazadas donde debían.

⚔️ Gobierna el mundo exterior de tu maquina
  1. Convierte un invoke con lógica en línea en uno registrado por nombre en setup.actors y comprueba que la máquina no cambia.
  2. Escribe la utilidad de promesa diferida y úsala para afirmar que el estado de carga es observable antes de la resolución.
  3. Registra el input recibido por el doble y verifica que la máquina lo calcula desde el context correcto.
  4. Rechaza la diferida y afirma el estado de fallo, el motivo conservado y que el reintento crea un actor nuevo.
  5. Lanza dos cargas seguidas resolviéndolas en orden inverso y comprueba qué resultado acaba en el context.
  6. Instrumenta un fromCallback con una limpieza que acumule llamadas y demuestra que salir del estado detiene el actor.