La transición como función pura: testear sin ejecutar la máquina
El test más barato y más estable de una máquina de estado no arranca nada. Esta lección desarrolla la prueba de la función de transición como objeto matemático: `initialTransition` y `transition` como par que devuelve snapshot y acciones sin ejecutarlas, la diferencia con `getNextSnapshot`, cómo afirmar simultáneamente sobre el valor de estado y sobre el `context`, cómo empezar una prueba en mitad del recorrido con `resolveState`, la escritura de la tabla de transiciones como tabla de casos, y qué clase de defectos esta técnica es incapaz de ver por construcción.
Toda la disciplina de testing de máquinas de estado descansa sobre un hecho que ya conoces pero cuyas consecuencias prácticas casi nunca se explotan: la transición es una función pura. Dado un snapshot y un evento, el snapshot siguiente está completamente determinado, y calcularlo no requiere un intérprete, ni un buzón, ni un reloj, ni una promesa, ni un componente montado. Eso significa que la mayor parte de la lógica de tu aplicación —la que decide qué es legal, qué es imposible y qué datos se actualizan— es verificable con la misma tecnología con la que se verifica una función de suma: entradas conocidas, salida esperada, cero infraestructura. Empezar por aquí no es empezar por lo fácil; es empezar por donde vive la mayoría de los defectos de diseño, y hacerlo con pruebas que corren en microsegundos y no parpadean jamás.
- Calcular estados con
initialTransitionytransitionsin crear ningún actor. - Distinguir la tupla pura de snapshot y acciones frente al atajo impuro
getNextSnapshot. - Afirmar a la vez sobre el valor de estado y sobre el
contextresultante. - Empezar una prueba en mitad del recorrido con
resolveStatey tabular casos.
El par que devuelve estado y acciones
XState expone el corazón determinista de la máquina como dos funciones libres. initialTransition recibe la lógica y devuelve el punto de partida; transition recibe la lógica, un snapshot y un evento, y devuelve el siguiente. Ambas retornan una tupla: primero el snapshot, después la lista de acciones que un intérprete debería ejecutar. No las ejecuta. Esa negativa es lo que mantiene el cálculo puro y lo que te permite afirmar sobre los efectos sin provocarlos.
import { initialTransition, transition } from 'xstate'
import { formulario } from './formulario'
const [s0] = initialTransition(formulario)
const [s1, acciones] = transition(formulario, s0, {
type: 'ESCRIBIR',
campo: 'correo',
valor: 'ana@ejemplo.com',
})
expect(s0.value).toBe('vacio')
expect(s1.value).toBe('editando')
expect(s1.context.correo).toBe('ana@ejemplo.com')
expect(acciones).toHaveLength(0)
La tercera aserción es la más interesante y la que casi nadie escribe: comprobar que una transición no programó ningún efecto. Un test que solo mira el estado destino da por buena una transición que además dispara una petición indebida; mirar la lista de acciones convierte los efectos en parte del contrato verificado en lugar de dejarlos como daño colateral invisible.
Conviene no confundir este par con su hermano impuro. getNextSnapshot y getInitialSnapshot existen para el caso en que quieras el snapshot final con las acciones ya aplicadas, y por eso ejecutan lo que encuentran. Sirven para exploraciones rápidas y para el recorrido de grafos, pero no son la herramienta de un test unitario que aspira a no tocar el mundo.
| API | Ejecuta las acciones | Devuelve | Uso natural |
|---|---|---|---|
initialTransition |
No | Tupla de snapshot y acciones | Punto de partida de un test puro |
transition |
No | Tupla de snapshot y acciones | Aserción sobre destino y efectos |
getNextSnapshot |
Sí | Solo el snapshot | Exploración y recorrido de grafos |
Dos aserciones que van siempre juntas
El error más común al testear una máquina es afirmar únicamente sobre snapshot.value. Una máquina bien diseñada reparte su significado entre dos planos: el estado finito, que dice en qué régimen está, y el context, que dice con qué datos. Verificar solo uno deja la mitad del contrato sin cubrir, y suele ser precisamente la mitad donde se esconden los defectos sutiles: la transición correcta que acumula mal, el assign que pisa un campo que debía conservarse, el contador que se reinicia una vez de más.
const eventos = [
{ type: 'ESCRIBIR', campo: 'correo', valor: 'a@b.c' },
{ type: 'ENVIAR' },
{ type: 'FALLO', motivo: 'red' },
{ type: 'REINTENTAR' },
] as const
const final = eventos.reduce(
(snap, ev) => transition(formulario, snap, ev)[0],
initialTransition(formulario)[0],
)
expect(final.value).toBe('enviando')
expect(final.context.intentos).toBe(2)
expect(final.context.correo).toBe('a@b.c') // sobrevivio al fallo
Ese reduce no es un truco de estilo: es la afirmación literal de que la máquina es un plegado sobre la secuencia de eventos. Escribir así los tests hace evidente la propiedad que los habilita —la historia completa de un actor está determinada por su lista de eventos— y produce, gratis, una forma cómoda de describir escenarios largos sin ceremonia. Cuando un caso falla, el diagnóstico es inmediato porque la secuencia está a la vista, en orden, sin await que la interrumpa.
Un evento que llega en un estado que no lo declara no lanza ni avisa: XState simplemente devuelve un snapshot equivalente al anterior. Esa tolerancia es una virtud en producción y una trampa en los tests. Escribe explícitamente los casos negativos —enviar PAGAR desde vacio y afirmar que el valor no cambió— porque son los que documentan qué es imposible, que es la razón por la que elegiste una máquina de estados en primer lugar.
Empezar en medio y tabular los casos
Reconstruir desde el estado inicial cada escenario es correcto pero costoso de leer cuando el recorrido es largo. machine.resolveState fabrica un snapshot válido a partir de un valor de estado y un context parcial, resolviendo por ti los estados compuestos, los paralelos y los valores por defecto. Es el equivalente a colocar la pieza en mitad del tablero sin haber jugado la partida.
const enviando = formulario.resolveState({
value: 'enviando',
context: { correo: 'a@b.c', intentos: 1 },
})
const [tras] = transition(formulario, enviando, { type: 'FALLO', motivo: 'red' })
expect(tras.value).toBe('error')
expect(tras.context.intentos).toBe(1)
Con ese punto de entrada, la tabla de transiciones deja de ser documentación y se convierte en el propio conjunto de casos. La correspondencia es exacta: cada fila de la tabla es una llamada a transition, y una tabla completa es una suite completa.
const casos = [
['vacio', { type: 'ESCRIBIR', campo: 'correo', valor: 'x' }, 'editando'],
['editando', { type: 'ENVIAR' }, 'enviando'],
['enviando', { type: 'EXITO' }, 'hecho'],
['enviando', { type: 'FALLO', motivo: 'red' }, 'error'],
['error', { type: 'REINTENTAR' }, 'enviando'],
['hecho', { type: 'ENVIAR' }, 'hecho'],
] as const
test.each(casos)('%s con %o va a %s', (origen, evento, destino) => {
const desde = formulario.resolveState({ value: origen })
expect(transition(formulario, desde, evento)[0].value).toBe(destino)
})
flowchart LR S[snapshot n] --> F[funcion de transicion] E[evento] --> F F --> S2[snapshot n mas 1] F --> A[lista de acciones sin ejecutar] style F fill:#89b4fa,color:#11111b style A fill:#f9e2af,color:#11111b
Lo que esta prueba no puede ver
Ser explícito sobre los límites de una técnica es parte de dominarla. La transición pura no ve nada que dependa del tiempo real, porque no hay reloj; no ve lo que ocurre dentro de un actor invocado, porque nadie lo arranca; no ve el orden de ejecución de las acciones ni sus efectos, porque las devuelve sin correrlas; y no ve si tu interfaz refleja el estado, porque no hay interfaz.
Lo que sí cubre
Legalidad de cada transición, guards, cálculo del context con assign, estados finales alcanzados y la ausencia de efectos indebidos en la lista de acciones.
Lo que exige un actor
Transiciones retardadas con after, actores invocados, comunicación entre actores y cualquier cosa que dependa de un buzón o de un reloj.
Lo que exige la UI
Que el componente pinte lo que el estado dice, que el evento correcto salga del clic correcto y que la accesibilidad acompañe a cada régimen.
Esa repartición sugiere la pirámide de este nivel entero: la mayoría de los casos en transiciones puras, unos pocos con el actor arrancado y el reloj bajo control, y una capa fina —generada, no escrita a mano— contra la interfaz real. Las tres lecciones siguientes recorren exactamente esos escalones.
Cuando pruebas una función corriente estás comprobando una implementación: si el cuerpo cambia, el test sigue siendo válido mientras el resultado coincida. Cuando pruebas una transición estás haciendo algo categóricamente distinto, y merece la pena nombrarlo. La máquina no es código que se ejecuta, es una estructura matemática —un conjunto de estados, un alfabeto de eventos y una relación total entre ambos— y tu test es una proposición sobre esa estructura: desde este estado, con este símbolo, el autómata aterriza aquí y su memoria queda así. La diferencia tiene tres consecuencias que cambian la práctica. La primera es que estos tests no son frágiles por naturaleza, porque no observan detalles internos sino la relación de transición, que es la especificación; refactorizar la implementación de una acción no los rompe, cambiar el comportamiento sí, que es justo lo que quieres. La segunda es que la cobertura deja de medirse en líneas ejecutadas y pasa a medirse en pares de estado y evento cubiertos, una métrica finita, enumerable y con un máximo alcanzable de verdad —cosa que la cobertura de líneas nunca ha tenido—. La tercera es la más incómoda para el hábito común: si tus casos no se pueden escribir como una tabla, no es que falte una utilidad de test, es que tu máquina no es una máquina; en algún sitio hay un if sobre el context haciendo el trabajo que debería hacer un estado, y el test acaba de diagnosticarlo antes que cualquier revisión. Por eso conviene escribir estas pruebas al mismo tiempo que la máquina y no después: no verifican un artefacto terminado, participan en decidir si el artefacto está bien planteado. Un autómata que resiste su tabla de transiciones es un diseño demostrado; uno que necesita andamiaje para probarse está pidiendo, a gritos, que lo vuelvas a dibujar.
- Escribe la tabla completa de transiciones de una máquina tuya y conviértela en un
test.eachconresolveStateytransition. - Añade a tres casos una aserción sobre el
contextresultante además de sobre el valor de estado. - Incluye al menos cuatro casos negativos: eventos ilegales que deben dejar el estado intacto.
- Toma la lista de acciones devuelta por
transitiony afirma que una transición concreta no programa ningún efecto. - Encadena una secuencia de seis eventos con
reducey comprueba que un dato delcontextsobrevive a un ciclo de fallo y reintento. - Localiza una rama de tu lógica que estos tests no puedan alcanzar y anota cuál de los tres escalones siguientes la cubrirá.