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

Testear el actor: snapshots, espera determinista y control del reloj

Hay comportamiento que solo existe cuando la máquina corre: transiciones retardadas, actores hijos, comunicación por buzón. Esta lección cubre el segundo escalón del testing: arrancar el actor con `createActor`, enviarle eventos y afirmar sobre su snapshot; sustituir los `sleep` por espera determinista con `waitFor` y `toPromise`; tratar el reloj como una dependencia inyectable mediante la opción `clock` para que un `after` de treinta segundos tarde cero milisegundos; y afirmar sobre la secuencia completa de snapshots con `subscribe` en vez de solo sobre el estado final.

⏱ 18 min

La transición pura cubre la legalidad del autómata, pero hay una franja del comportamiento que sencillamente no existe hasta que alguien interpreta la máquina: el temporizador que expira, el actor hijo que nace al entrar en un estado, el evento que llega desde un buzón, la transición que ocurre sola porque han pasado treinta segundos. Testear eso obliga a arrancar un actor, y arrancar un actor introduce dos enemigos clásicos de la fiabilidad: la asincronía y el tiempo real. La respuesta profesional a ambos no es esperar más, es eliminar la espera. La asincronía se doma afirmando sobre condiciones y no sobre instantes; el tiempo se doma tratando el reloj como lo que XState declara que es, una dependencia que se inyecta, y no como una ley física a la que haya que someterse.

🎯 Al terminar esta lección sabrás
  • Arrancar un actor, enviarle eventos y afirmar sobre getSnapshot y matches.
  • Sustituir esperas arbitrarias por waitFor y toPromise, deterministas por construcción.
  • Inyectar un reloj simulado con la opción clock para controlar after y delays.
  • Afirmar sobre la secuencia completa de snapshots capturada con subscribe.

Arrancar, enviar y afirmar

El ciclo básico es directo y merece un par de precisiones que evitan la mayoría de los tests inestables que se ven en la práctica. createActor construye pero no arranca; start lleva la máquina a su estado inicial y ejecuta las acciones de entrada; send deposita un evento en el buzón, y el procesamiento es síncrono para todo lo que no dependa de una tarea pendiente; stop libera actores hijos y suscriptores.

import { createActor } from 'xstate'
import { sesion } from './sesion'

test('un login correcto deja al usuario autenticado', () => {
  const actor = createActor(sesion).start()

  actor.send({ type: 'ENTRAR', usuario: 'ana', clave: 'secreta' })

  expect(actor.getSnapshot().matches('verificando')).toBe(true)
  expect(actor.getSnapshot().context.usuario).toBe('ana')

  actor.stop()
})

Usa matches en lugar de comparar value con una cadena siempre que haya jerarquía: matches entiende los estados compuestos y acepta la notación de objeto y la de punto, de modo que sobrevive a que mañana introduzcas un subestado. Comparar value con igualdad estricta te ata a la forma exacta del árbol y convierte cualquier refinamiento de la máquina en una cascada de tests rotos por una razón que nada tiene que ver con el comportamiento.

Llama siempre a stop al terminar. Un actor que sigue vivo entre tests conserva temporizadores, suscripciones y actores hijos, y esa fuga se manifiesta más tarde como un fallo intermitente en un test que no la causó. Registrarlo en un afterEach centralizado es la forma barata de no depender de la memoria de nadie.

💡
Un actor por test, siempre

Compartir un actor entre casos crea acoplamiento por orden de ejecución: el segundo test pasa solo si el primero corrió antes, y la suite deja de ser paralelizable. Como la máquina es un valor puro, crear un actor nuevo cuesta prácticamente nada; no hay razón alguna para reutilizarlo. Si un escenario necesita un punto de partida avanzado, arranca desde un snapshot persistido en vez de heredar el actor del test anterior.

Esperar sin dormir

En cuanto hay un actor invocado o una transición retardada, el estado que quieres afirmar llega en otro turno del bucle de eventos. La tentación es meter una espera fija de unos milisegundos, y esa es exactamente la práctica que produce suites que fallan una vez de cada treinta en la máquina de integración continua. XState ofrece dos primitivas que convierten la espera en una condición explícita.

import { createActor, waitFor, toPromise } from 'xstate'

test('la carga termina en exito', async () => {
  const actor = createActor(maquinaCarga).start()
  actor.send({ type: 'CARGAR', id: '7' })

  const snap = await waitFor(actor, (s) => s.matches('exito'), { timeout: 1_000 })
  expect(snap.context.datos).not.toBeNull()

  actor.stop()
})

test('una maquina con estado final entrega su output', async () => {
  const salida = await toPromise(createActor(maquinaTarea).start())
  expect(salida.recibo).toMatch(/^R-/)
})

waitFor resuelve en cuanto un snapshot satisface el predicado y rechaza si expira el plazo, con lo que el test tarda lo mínimo posible y falla con un mensaje que dice qué condición nunca se cumplió. toPromise es la variante para máquinas con estado final: resuelve con el output del actor cuando llega a done y rechaza si la máquina termina en error. La diferencia entre dormir y esperar una condición es la diferencia entre un test que es lento y frágil a la vez y uno que es rápido y estable a la vez.

El reloj es una dependencia

Una transición declarada con after no consulta un reloj global: pide su temporizador al objeto que el actor recibe en la opción clock. Ese objeto solo necesita dos métodos, setTimeout y clearTimeout, con la misma forma que los del entorno. Implementarlos tú convierte el paso del tiempo en una llamada a un método, y un plazo de treinta segundos en una prueba instantánea.

class RelojSimulado {
  private ahora = 0
  private siguienteId = 0
  private tareas = new Map<number, { en: number; fn: () => void }>()

  setTimeout(fn: () => void, ms: number) {
    const id = this.siguienteId++
    this.tareas.set(id, { en: this.ahora + ms, fn })
    return id
  }

  clearTimeout(id: number) { this.tareas.delete(id) }

  avanzar(ms: number) {
    this.ahora += ms
    const vencidas = [...this.tareas].filter(([, t]) => t.en <= this.ahora)
    for (const [id, tarea] of vencidas.sort((a, b) => a[1].en - b[1].en)) {
      this.tareas.delete(id)
      tarea.fn()
    }
  }
}

const reloj = new RelojSimulado()
const actor = createActor(sesion, { clock: reloj }).start()

actor.send({ type: 'ENTRAR', usuario: 'ana', clave: 'secreta' })
expect(actor.getSnapshot().matches('verificando')).toBe(true)

reloj.avanzar(30_000)                                   // caduca el plazo
expect(actor.getSnapshot().matches('expirado')).toBe(true)

Hay una segunda vía, complementaria y a veces más cómoda: declarar los retardos por nombre en setup.delays y sustituirlos en el test con machine.provide, poniendo a cero los que no quieras esperar. La regla para elegir entre ambas es clara. Si el retardo es un detalle de implementación que solo estorba, sustitúyelo por cero; si el propio plazo es la regla de negocio que quieres verificar —que a los treinta segundos exactos, y no antes, la sesión caduca—, necesitas el reloj simulado, porque solo él te permite afirmar el borde: avanzar veintinueve segundos y comprobar que todavía no ha pasado nada.

⚠️
Los relojes falsos globales y las promesas no siempre se llevan bien

Las utilidades de temporizadores falsos del ejecutor de tests interceptan setTimeout global, pero no adelantan las microtareas de una promesa pendiente. En una máquina que mezcla after con actores invocados, adelantar el reloj sin ceder el turno al bucle de eventos deja transiciones a medio camino. Inyectar el reloj por la opción clock evita el problema de raíz porque no toca el entorno global; si aun así usas temporizadores falsos, intercala una espera con waitFor después de cada avance.

sequenceDiagram
participant T as test
participant A as actor
participant R as reloj simulado
T->>A: start
T->>A: send ENTRAR
A->>R: setTimeout 30000
T->>R: avanzar 29000
T->>A: getSnapshot sigue verificando
T->>R: avanzar 1000
R->>A: dispara el temporizador
T->>A: getSnapshot ahora expirado

Afirmar la secuencia, no solo el final

Un snapshot final idéntico puede provenir de recorridos distintos, y a veces el recorrido es el requisito: que se haya pasado por cargando antes de exito es lo que garantiza que el usuario vio un indicador. Suscribirse y acumular los valores convierte esa exigencia en una aserción explícita.

const vistos: string[] = []
const actor = createActor(maquinaCarga)
actor.subscribe((s) => vistos.push(JSON.stringify(s.value)))
actor.start()

actor.send({ type: 'CARGAR', id: '7' })
await waitFor(actor, (s) => s.matches('exito'))

expect(vistos).toEqual(['"inactivo"', '"cargando"', '"exito"'])
📍

Estado puntual

getSnapshot responde qué ocurre ahora. Es lo adecuado para afirmar el resultado de un envío síncrono.

Condición futura

waitFor espera a que se cumpla un predicado con un plazo máximo. Sustituye a cualquier espera fija.

🎞️

Trayectoria

subscribe acumula la película completa y permite afirmar que se pasó por los estados intermedios en el orden debido.

Cuando el tiempo se inyecta, deja de ser una fuente de azar y pasa a ser un parámetro del teorema

El motivo por el que las pruebas de comportamiento temporal tienen tan mala fama en toda la industria no es que el tiempo sea intrínsecamente difícil, sino que casi todos los sistemas lo consumen desde una variable global implícita. Un setTimeout escrito directamente en una función acopla esa función al reloj de la máquina que la ejecuta, y con ello importa toda su varianza: la carga del servidor de integración, el planificador del sistema operativo, la casualidad de que el recolector de basura pase justo entonces. El resultado es la patología conocida —el test que pasa en tu portátil y falla los viernes— y su remedio habitual, subir el plazo de espera, que no arregla nada y encima paga el coste en cada ejecución futura. XState toma la decisión estructuralmente correcta: el reloj no se consume, se recibe. Al declarar clock como una opción del actor, el paso del tiempo deja de ser un hecho del entorno y se convierte en un parámetro del sistema, exactamente igual que una dependencia de red o de almacenamiento. Y ahí es donde aparece el salto conceptual que da nombre a esta lección: si el tiempo es un parámetro, entonces una afirmación sobre comportamiento temporal deja de ser una apuesta probabilística y pasa a ser una demostración con hipótesis explícita. Bajo este reloj, tras exactamente veintinueve mil milisegundos, la sesión sigue viva; tras treinta mil, ha caducado. Eso no es un test que a veces pasa: es una proposición verdadera sobre un autómata, verificable en un microsegundo y reproducible para siempre. La consecuencia práctica es de otro orden de magnitud, porque desbloquea la verificación de todo un continente de lógica que la mayoría de los equipos deja sin probar por resultar demasiado lenta o demasiado inestable: expiraciones de sesión, ventanas de reintento con retroceso exponencial, plazos de gracia, antirrebotes, latidos de conexión. Todo eso se vuelve barato en el instante en que aceptas que el reloj es una dependencia. Y hay un corolario de diseño que trasciende el testing: un sistema en el que el tiempo se inyecta es también un sistema en el que el tiempo se puede acelerar, rebobinar o simular en producción —para una demo, para un ensayo de recuperación ante desastres, para reproducir un incidente—. La testabilidad, aquí como casi siempre, no era una propiedad de las pruebas sino un síntoma de que la arquitectura estaba bien cortada.

⚔️ Doma el tiempo de tu maquina
  1. Escribe un test que arranque un actor, le envíe dos eventos síncronos y afirme con matches sobre un estado compuesto.
  2. Añade un afterEach que detenga todos los actores creados y comprueba que la suite sigue verde al ejecutarla en paralelo.
  3. Sustituye cualquier espera fija de tu suite por waitFor con predicado y plazo, y mide cuánto baja la duración total.
  4. Implementa el reloj simulado de la lección e inyéctalo para verificar el borde exacto de una transición after.
  5. Verifica el caso contrario: avanza el reloj hasta un milisegundo antes del plazo y afirma que el estado todavía no cambió.
  6. Captura la secuencia de snapshots con subscribe y afirma que un flujo de reintento pasa dos veces por el estado de carga.