Cobertura del grafo: recorrer todos los estados y todas las transiciones
Una máquina es un grafo dirigido y un grafo se puede recorrer con un algoritmo, no con la intuición del que escribe los tests. Esta lección usa `@xstate/graph` para materializar ese grafo: `getAdjacencyMap` para enumerar lo alcanzable, `getShortestPaths` y `getSimplePaths` para generar recorridos, y a partir de ahí un diagnóstico automático de dos patologías estructurales —el estado inalcanzable y el callejón sin salida— junto con una noción de cobertura medida en pares de estado y evento en lugar de en líneas ejecutadas.
La cobertura de líneas es una métrica prestada de un mundo en el que el programa es una secuencia de instrucciones. Una máquina de estados no es eso: es un grafo dirigido con un conjunto finito de nodos y un conjunto finito de aristas etiquetadas por eventos. Preguntar qué porcentaje de sus líneas se ejecutó carece de sentido, pero preguntar qué porcentaje de sus transiciones se ejercitó tiene una respuesta exacta, enumerable y alcanzable de verdad. Y como el grafo es un dato —la máquina es un valor serializable—, ese recorrido no se escribe a mano: se calcula. @xstate/graph convierte la topología de tu autómata en estructuras que puedes explorar programáticamente, y con ellas se detectan en segundos dos defectos que ninguna revisión de código encuentra con fiabilidad: el estado al que nadie puede llegar y el estado del que nadie puede salir.
- Enumerar los estados realmente alcanzables con
getAdjacencyMapy contrastarlos con los declarados. - Generar recorridos con
getShortestPathsygetSimplePathsy saber cuándo sirve cada uno. - Detectar automáticamente estados inalcanzables y callejones sin salida no declarados como finales.
- Definir la cobertura como fracción de pares de estado y evento ejercitados.
El grafo que ya está en tu máquina
Cada estado es un nodo, cada transición una arista etiquetada por un evento, y cada guard un filtro que hace que ciertas aristas solo existan bajo ciertos contextos. Esa última precisión es esencial y explica por qué el recorrido necesita ejecutar la transición en vez de leer solo la definición: la topología efectiva depende del context, así que el grafo se descubre explorando, no inspeccionando. getAdjacencyMap hace exactamente eso: parte del estado inicial, prueba cada evento del alfabeto que le des y expande los estados nuevos que aparezcan hasta agotar lo alcanzable.
import { getAdjacencyMap } from '@xstate/graph'
import { pedido } from './pedido'
const alfabeto = [
{ type: 'ANADIR', sku: 'A1' },
{ type: 'PAGAR' },
{ type: 'FALLO_PAGO' },
{ type: 'REINTENTAR' },
{ type: 'CANCELAR' },
] as const
const mapa = getAdjacencyMap(pedido, { events: alfabeto })
const alcanzables = new Set(
Object.values(mapa).map((nodo) => JSON.stringify(nodo.state.value)),
)
El alfabeto es tuyo y es la decisión más importante de todo el proceso. Un evento que olvides declarar deja fuera todas las aristas que dependen de él, y con ellas los estados a los que solo se llega por ahí, que aparecerán como inalcanzables sin serlo. Por eso conviene derivar el alfabeto de los tipos de evento de la máquina y no escribirlo a mano; si tu unión de eventos suma PAGAR, CANCELAR y REINTENTAR, la lista de casos debe cubrir esa unión entera, incluidas las variantes de carga útil que activan guards distintos.
Dos algoritmos de recorrido
@xstate/graph ofrece dos generadores de caminos con propósitos complementarios. getShortestPaths devuelve, para cada estado alcanzable, la ruta mínima desde el inicio: es una anchura primero, produce un camino por destino y garantiza que cada estado se visita al menos una vez con el mínimo número de pasos. getSimplePaths devuelve todas las rutas sin repetir nodos: son muchas más, cubren combinaciones de aristas que el camino mínimo se salta, y su número crece muy deprisa.
import { getShortestPaths, getSimplePaths } from '@xstate/graph'
const minimos = getShortestPaths(pedido, { events: alfabeto })
const simples = getSimplePaths(pedido, { events: alfabeto })
for (const camino of minimos) {
const eventos = camino.steps.map((paso) => paso.event.type).join(' -> ')
console.log(`${JSON.stringify(camino.state.value)}: ${eventos}`)
}
expect(minimos.length).toBe(alcanzables.size)
| Generador | Qué produce | Coste | Cuándo usarlo |
|---|---|---|---|
getShortestPaths |
Una ruta mínima por estado alcanzable | Lineal en el grafo | Cobertura de estados, humo rápido |
getSimplePaths |
Todas las rutas sin nodos repetidos | Puede explotar | Cobertura de transiciones y combinaciones |
getPathsFromEvents |
La ruta que produce una secuencia dada | Trivial | Reproducir un incidente concreto |
La elección práctica es escalonada. En cada cambio se ejecutan los caminos mínimos, que son pocos y rápidos y garantizan que todos los estados siguen siendo alcanzables. En la ejecución nocturna se ejecutan los caminos simples, acotando la búsqueda con las opciones de filtro y de parada para que la combinatoria no se desborde. Y cuando aparece un fallo en producción, getPathsFromEvents reconstruye el recorrido exacto a partir de la secuencia de eventos registrada, que es la forma más directa que existe de convertir un incidente en un test de regresión.
Los generadores aceptan opciones para limitar la exploración: un predicado que descarta ramas irrelevantes, una condición de parada al alcanzar cierto estado y un tope de profundidad. Úsalas desde el principio, no cuando la suite ya tarde diez minutos. Un context con un contador sin cota superior convierte el grafo en infinito, porque cada valor del contador es un estado distinto a ojos del explorador; acotar ese contador en el modelo de prueba, o excluirlo de la identidad del nodo, es lo que devuelve la finitud.
Dos patologías que el grafo delata
Con el mapa de adyacencia en la mano, dos comprobaciones estructurales caben en unas pocas líneas y valen por muchas revisiones de código.
const declarados = Object.keys(pedido.config.states ?? {})
const inalcanzables = declarados.filter((s) => !alcanzables.has(JSON.stringify(s)))
expect(inalcanzables).toEqual([])
const callejones = Object.values(mapa)
.filter((nodo) => Object.keys(nodo.transitions).length === 0)
.filter((nodo) => nodo.state.status !== 'done')
.map((nodo) => JSON.stringify(nodo.state.value))
expect(callejones).toEqual([])
Un estado inalcanzable es código muerto con disfraz de diseño: o sobra, o hay un guard que nunca puede ser cierto, o falta la transición que debía llevar hasta él. Un callejón sin salida que no está marcado como type: 'final' es peor, porque es una interfaz congelada en producción —el usuario llega y ya no puede hacer nada, y ninguna prueba manual da con él porque nadie recorre esa rama a mano—. Las dos comprobaciones son baratas, no requieren mantenimiento y fallan exactamente el día en que alguien introduce el defecto.
flowchart LR I[inicial] --> C[carrito] C --> P[pagando] P --> E[exito] P --> F[fallo] F --> P F --> X[cancelado sin salida] H[huerfano inalcanzable] style X fill:#f38ba8,color:#11111b style H fill:#f38ba8,color:#11111b style E fill:#a6e3a1,color:#11111b
Cobertura medida en aristas
La métrica que sustituye a la cobertura de líneas es la fracción de pares de estado y evento efectivamente ejercitados. Se calcula recorriendo el mapa de adyacencia para obtener el denominador —todas las aristas existentes— y acumulando durante la ejecución de los caminos el numerador.
const todas = new Set<string>()
for (const nodo of Object.values(mapa)) {
for (const clave of Object.keys(nodo.transitions)) {
todas.add(`${JSON.stringify(nodo.state.value)} @ ${clave}`)
}
}
const ejercitadas = new Set<string>()
for (const camino of simples) {
for (const paso of camino.steps) {
ejercitadas.add(`${JSON.stringify(paso.state.value)} @ ${paso.event.type}`)
}
}
const cobertura = ejercitadas.size / todas.size
expect(cobertura).toBe(1)
Cobertura de estados
Cada nodo alcanzable se visita al menos una vez. La garantizan los caminos mínimos y es el mínimo exigible en cada cambio.
Cobertura de transiciones
Cada arista se recorre al menos una vez. Es la métrica útil: incluye la de estados y detecta la rama de reintento que nadie prueba.
Cobertura de caminos
Cada ruta distinta se recorre entera. Es inalcanzable en cuanto hay ciclos, y perseguirla es un error de juicio, no de herramienta.
Durante treinta años la industria ha medido la calidad de sus pruebas contando qué porcentaje del texto del programa se ejecutó, y esa métrica tiene un defecto que su propia popularidad ha conseguido ocultar: es una propiedad de la representación, no del comportamiento. Puedes alcanzar el cien por cien de líneas ejecutando cada instrucción exactamente una vez, en un único orden, sin haber probado jamás la segunda vez que se entra en un estado, ni la combinación de dos banderas, ni la rama que solo existe cuando el contador ya pasó de tres. La cobertura de líneas mide el mapa. La cobertura de transiciones mide el territorio, y solo es posible porque una máquina de estados hace algo que el código imperativo no hace: expone su espacio de comportamiento como una estructura finita y enumerable. Esa es la ganancia epistemológica de modelar con autómatas, y es mucho mayor que la comodidad de tener un diagrama bonito. Cuando el conjunto de comportamientos posibles es enumerable, la pregunta lo hemos probado todo pasa de ser una aspiración retórica a ser una consulta con respuesta booleana, y el residuo no cubierto deja de ser una nube de incertidumbre para convertirse en una lista concreta de pares de estado y evento que alguien puede leer en una revisión y decidir, uno por uno, si merecen prueba o merecen desaparecer. De ahí se sigue el segundo efecto, más silencioso y quizá más valioso: el análisis del grafo es simultáneamente una crítica del diseño. Un estado inalcanzable no es un hueco de cobertura, es una afirmación falsa que llevaba meses en el repositorio; un callejón sin salida no es una rama sin probar, es un usuario atrapado que todavía no se ha quejado; una explosión combinatoria al pedir los caminos simples no es un problema de rendimiento del explorador, es el diagnóstico de que has metido en el context algo que debía ser un estado, o de que dos preocupaciones ortogonales están enredadas en una sola máquina que debería partirse en dos regiones paralelas. Por eso conviene ejecutar estas comprobaciones no como una tarea de calidad al final, sino como una lente de diseño mientras modelas: el grafo te devuelve, en forma de números y de listas, el juicio arquitectónico que de otro modo tendrías que fabricar con intuición y experiencia. Contar aristas resulta ser, sorprendentemente, una manera rigurosa de pensar.
- Construye el alfabeto de eventos de una máquina tuya derivándolo de su unión de tipos, no a mano.
- Calcula el mapa de adyacencia y escribe la aserción de que no hay estados declarados inalcanzables.
- Añade la comprobación de callejones sin salida y decide, para cada hallazgo, si falta una transición o sobra un estado.
- Genera los caminos mínimos y ejecútalos como una prueba de humo que garantice que todo estado sigue alcanzable.
- Mide la cobertura de transiciones con los caminos simples y lista las aristas que quedan sin cubrir.
- Introduce a propósito un
guardimposible y comprueba que el análisis del grafo lo delata como estado inalcanzable.