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

Model-based testing: que el modelo escriba los casos y la UI los sufra

Si la máquina ya describe todo el comportamiento posible, escribir los casos a mano es redundante y además sesgado. `@xstate/test` invierte la autoría: genera recorridos desde el grafo y los ejecuta contra la aplicación real, pidiéndote solo dos contratos —cómo se verifica cada estado en la pantalla y cómo se provoca cada evento—. Esta lección cubre la anatomía del modelo de prueba, la disciplina de aislamiento entre caminos, el criterio para acotar la explosión combinatoria y qué clase de defectos aparecen cuando quien elige los casos no es el mismo que escribió el código.

⏱ 19 min

Todo test escrito a mano comparte un sesgo con el código que verifica: lo escribió alguien que ya tenía un modelo mental de cómo funciona la aplicación, y ese modelo mental es justamente el que contiene los puntos ciegos que producen los defectos. Por eso las suites manuales cubren con generosidad el camino feliz y con negligencia la ruta de reintentar tras cancelar tras un fallo de red. El testing basado en modelos rompe ese sesgo por la vía estructural: no eliges tú los casos, los enumera un algoritmo sobre el grafo, y el algoritmo no tiene la costumbre de suponer que nadie pulsaría dos veces el botón de pagar. Tu trabajo se reduce a dos contratos honestos: decir cómo se reconoce cada estado en la pantalla real y cómo se provoca cada evento sobre ella. Todo lo demás —qué secuencias, en qué orden, cuántas— lo decide la topología.

🎯 Al terminar esta lección sabrás
  • Construir un modelo de prueba con createTestModel a partir de una máquina existente.
  • Escribir los dos contratos que el modelo exige: verificación de estados y ejecución de eventos.
  • Garantizar el aislamiento entre caminos generados y diagnosticar los fallos que produce.
  • Acotar la explosión combinatoria eligiendo generador, filtros y frecuencia de ejecución.

Invertir quién elige los casos

La técnica descansa sobre una separación que conviene enunciar con precisión. Por un lado está el modelo: la máquina de estados que ya escribiste, que es la especificación del comportamiento. Por otro está el sistema bajo prueba: el componente real, con su árbol de nodos, sus peticiones y su navegador. El modelo genera recorridos; el sistema los ejecuta; una discrepancia entre lo que el modelo afirma y lo que la pantalla muestra es un defecto —en el código, o en el modelo, y averiguar cuál de los dos es el hallazgo más valioso de todos—.

import { createTestModel } from '@xstate/test'
import { caja } from './caja'

const modelo = createTestModel(caja)

describe('caja de pago', () => {
  for (const camino of modelo.getShortestPaths()) {
    it(camino.description, async () => {
      await camino.test(contratos)
    })
  }
})

Ese bucle es todo el andamiaje. Cada camino se convierte en un caso con nombre autogenerado —una descripción legible del recorrido— y la suite crece automáticamente cuando alguien añade un estado a la máquina. Esa última propiedad es la que cambia la economía del testing: un estado nuevo no exige escribir tests nuevos, los exige implementar, porque el generador ya está pidiendo que le digas cómo se verifica.

ℹ️
Un paquete en movimiento sobre una base estable

La superficie de @xstate/test ha cambiado entre la línea 4 y la línea 5 de XState: las aserciones que antes vivían en el meta de cada estado ahora se pasan al ejecutar el camino. Lo que no ha cambiado es el motor: por debajo está @xstate/graph con los mismos generadores de la lección anterior. Si la versión que tienes instalada difiere en detalles, la técnica se reconstruye entera sobre getShortestPaths y un bucle propio; el valor está en la idea, no en la firma.

Los dos contratos

El modelo no sabe nada de tu interfaz, y esa ignorancia es deliberada. Le entregas dos diccionarios. El primero, indexado por estado, contiene aserciones que deben cumplirse cuando el recorrido pasa por ahí. El segundo, indexado por tipo de evento, contiene la acción que provoca ese evento sobre la aplicación real.

const contratos = {
  states: {
    vacio: () => {
      expect(pantalla.getByRole('button', { name: 'Pagar' })).toBeDisabled()
      expect(pantalla.queryByText(/error/i)).toBeNull()
    },
    listo: () => {
      expect(pantalla.getByRole('button', { name: 'Pagar' })).toBeEnabled()
    },
    pagando: () => {
      expect(pantalla.getByRole('progressbar')).toBeVisible()
    },
    fallo: () => {
      expect(pantalla.getByRole('alert')).toHaveTextContent(/no se pudo cobrar/i)
      expect(pantalla.getByRole('button', { name: 'Reintentar' })).toBeEnabled()
    },
  },
  events: {
    ANADIR: async () => {
      await usuario.click(pantalla.getByRole('button', { name: 'Anadir' }))
    },
    PAGAR: async () => {
      await usuario.click(pantalla.getByRole('button', { name: 'Pagar' }))
    },
    REINTENTAR: async () => {
      await usuario.click(pantalla.getByRole('button', { name: 'Reintentar' }))
    },
  },
}

Hay una regla de oro en el primer diccionario: las aserciones de estado deben mirar lo que el usuario percibe, no el estado interno. Comprobar que el actor está en pagando sería tautológico —el modelo ya lo sabe— y no verificaría nada. Comprobar que hay un indicador visible y que el botón está deshabilitado sí verifica algo real: que la traducción de estado a interfaz es correcta, que es exactamente el trozo de código que el modelo no puede garantizar por sí solo.

En el segundo diccionario la regla es de simetría: cada evento del alfabeto necesita su ejecutor, y un evento sin ejecutor no bloquea la generación, simplemente hace que esa arista no se recorra jamás. Conviene afirmarlo explícitamente para que un olvido no se convierta en un agujero silencioso de cobertura.

flowchart TB
M[maquina como especificacion] --> G[generador de caminos]
G --> C1[camino 1]
G --> C2[camino 2]
G --> C3[camino n]
C1 --> R[ejecutor]
C2 --> R
C3 --> R
R --> V[verificar estado en la pantalla]
R --> E[provocar evento sobre la pantalla]
V --> D[discrepancia igual a defecto]
style G fill:#89b4fa,color:#11111b
style D fill:#f38ba8,color:#11111b

Aislamiento, o la suite que se envenena a sí misma

Cada camino generado debe partir de un sistema idéntico. Si el caso anterior dejó un artículo en el carrito, una petición en vuelo o un token en almacenamiento local, el siguiente arranca desde un mundo que el modelo no contempla, y el fallo resultante apuntará al camino equivocado. La disciplina es la misma que en cualquier suite de integración, solo que aquí resulta obligatoria porque los casos son muchos y su orden lo decide un algoritmo.

beforeEach(async () => {
  servidorFalso.reiniciar()
  localStorage.clear()
  pantalla = montarCaja()          // render aislado del componente
})

afterEach(() => {
  pantalla.unmount()
})

Un síntoma inconfundible de aislamiento roto: los caminos cortos pasan y los largos fallan, o la suite pasa entera al ejecutarla en un orden y falla en otro. Cuando eso aparezca, no toques las aserciones; el defecto está en el montaje, no en el modelo. Y si al reiniciar entre casos la suite se vuelve intolerablemente lenta, la respuesta correcta no es compartir el estado sino reducir el número de caminos, que es el tema de la sección siguiente.

Dónde poner el freno

El número de caminos simples de un grafo con ciclos crece de forma explosiva, y una suite basada en modelos sin cotas puede pasar de treinta casos a tres mil por añadir un solo estado con dos aristas de vuelta. La gestión de ese riesgo es una decisión de ingeniería, no un ajuste técnico.

Estrategia Efecto sobre el número de casos Coste de la decisión
Solo caminos mínimos Uno por estado alcanzable No cubre combinaciones de aristas
Caminos simples con tope de profundidad Crecimiento acotado Deja fuera flujos largos legítimos
Filtrar ramas por predicado sobre el context Recorte dirigido Exige criterio y se puede equivocar
Partir la máquina en regiones paralelas Producto convertido en suma Refactor real del modelo

La última fila es la única que no sacrifica cobertura, y por eso es la más interesante. Si el grafo explota, casi siempre es porque dos preocupaciones independientes —el estado del carrito y el estado de la sesión, digamos— comparten una máquina y multiplican sus estados. Separarlas en regiones ortogonales convierte un producto cartesiano en una suma, y el número de caminos cae de golpe sin perder nada. La explosión combinatoria, aquí como en el capítulo de Harel, es un mensaje del diseño y no un límite de la herramienta.

En cada cambio

Caminos mínimos contra componentes montados en memoria. Decenas de casos, segundos de ejecución, garantía de que todo estado sigue siendo alcanzable y visible.

🌙

En la ejecución nocturna

Caminos simples con tope de profundidad contra el navegador real. Cientos de casos, minutos de ejecución, cobertura de transiciones completa.

🐛

Ante un incidente

La secuencia de eventos registrada en producción reconstruida como camino y fijada como caso de regresión permanente.

Cuando el modelo genera los casos, el test deja de comprobar el codigo y pasa a comprobar el acuerdo entre dos descripciones

Un test escrito a mano es, en el fondo, una segunda implementación del mismo comportamiento, expresada en otro lenguaje y ejecutada por otra persona; su valor proviene de que dos implementaciones independientes rara vez se equivocan igual. El problema es que la independencia es una ficción: quien escribe el test suele ser quien escribió el código, el mismo día, con el mismo modelo mental, y por tanto con los mismos puntos ciegos. El testing basado en modelos ataca ese defecto en su raíz al sustituir la segunda implementación por una especificación declarativa —la máquina— y delegar la enumeración de escenarios a un algoritmo que carece de intuiciones y por eso no las tiene sesgadas. Lo que emerge de ahí no es un test mejor, es un objeto epistemológicamente distinto: ya no comprueba si el código hace lo que su autor creía, sino si dos descripciones del mismo sistema —una abstracta y ejecutable, otra concreta y visible— coinciden en todos los puntos que la primera declara posibles. De esa reformulación se sigue el rasgo que más desconcierta al usarlo por primera vez: cuando un caso generado falla, no sabes de antemano cuál de las dos descripciones está equivocada, y la investigación puede terminar corrigiendo el modelo en lugar del código. Eso, que parece una molestia, es la propiedad más valiosa del método, porque significa que la especificación está sometida a verificación empírica igual que la implementación, y que ambas convergen por presión mutua en vez de divergir en silencio como hacen un diagrama de arquitectura y su sistema real al cabo de un año. Hay además un efecto de segundo orden sobre la práctica del equipo que rara vez se menciona y que suele ser el que convence a los escépticos: como el modelo es la fuente de los casos, mantenerlo actualizado deja de depender de la buena voluntad y pasa a ser obligatorio, porque un modelo desactualizado no produce documentación obsoleta —produce tests que fallan hoy—. La deriva entre lo que el sistema hace y lo que decimos que hace, que es la enfermedad crónica de todo proyecto que dura, queda estructuralmente impedida. Y el corolario final invierte la jerarquía habitual: el diagrama deja de ser un dibujo que ilustra el código y se convierte en la autoridad que lo juzga.

⚔️ Deja que el grafo escriba tu suite
  1. Toma una máquina que ya gobierne un componente real y construye su modelo de prueba con createTestModel.
  2. Escribe el diccionario de estados afirmando solo sobre lo que el usuario percibe, nunca sobre el estado interno del actor.
  3. Escribe el diccionario de eventos y añade una comprobación de que ningún tipo de evento se ha quedado sin ejecutor.
  4. Genera los caminos mínimos, ejecútalos y cuenta cuántos defectos aparecen en rutas que nunca habías probado a mano.
  5. Rompe el aislamiento a propósito quitando el reinicio entre casos y observa el patrón de fallos que produce.
  6. Pide los caminos simples sin cota, mide cuántos salen y decide si acotas la búsqueda o partes la máquina en regiones paralelas.