wandres.dev
TESTING DEL ESTADO · lógica sin UI

Testing basado en modelos

El testing basado en propiedades encuentra entradas que rompen una ley, pero muchas de las verdades interesantes de una aplicación no son leyes sobre una entrada aislada sino sobre trayectorias: lo que falla no es una acción, sino una secuencia de acciones que nadie había puesto en ese orden. El testing basado en modelos lleva la generación aleatoria del valor a la secuencia. Se escribe una segunda implementación del sistema, deliberadamente ingenua, lenta y obviamente correcta —el modelo— y se deja que el motor genere miles de secuencias de comandos que se ejecutan simultáneamente contra el modelo y contra la implementación real, comparando ambos después de cada paso. La primera divergencia es el fallo, y la traza reducida que la produce es el informe de error más preciso que existe. Esta lección desarrolla la anatomía completa de la técnica: qué cualidades debe tener un buen modelo y por qué la ineficiencia es una virtud en él, cómo se escribe un comando con su precondición, su efecto sobre el modelo y su comprobación sobre el sistema real, cómo fc.modelRun encadena todo eso, y cómo se lee la traza mínima resultante. Termina delimitando con honestidad los límites de la técnica: qué clase de fallos jamás encontrará y cuándo escribir el modelo cuesta más de lo que ahorra.

⏱ 22 min

Hay una categoría de errores que ni la tabla de casos ni la propiedad sobre una entrada aislada pueden atrapar, y es justamente la categoría que más caro sale en producción: los errores de trayectoria. No están en ninguna transición individual, que examinada por separado es impecable, sino en la interacción entre transiciones separadas en el tiempo. Un elemento se selecciona, luego se filtra la lista y el elemento seleccionado desaparece de la vista pero sigue en la selección; se abre un panel, se navega, se vuelve y el panel recuerda un identificador que ya no existe; se aplica un descuento, se vacía el carrito y el descuento sobrevive al vaciado. Cada paso es correcto. La secuencia no lo es. Encontrar esos fallos a mano exige imaginar el orden preciso en que tres o cuatro acciones se sabotean, y esa imaginación es exactamente lo que ningún ingeniero tiene en cantidad suficiente. El testing basado en modelos automatiza esa imaginación mediante una idea de una elegancia desconcertante: escribir el sistema dos veces. Una vez de verdad, con todas sus optimizaciones, sus índices, sus cachés y su rendimiento; y otra vez de mentira, del modo más tonto e ineficiente posible, tan simple que su corrección se pueda verificar leyéndolo. Después se lanzan miles de secuencias aleatorias de acciones contra ambas versiones a la vez y se comprueba, paso a paso, que siguen contándose la misma historia. En el instante en que discrepan, una de las dos está mal, y casi siempre no es la tonta.

🎯 Al terminar esta lección sabrás
  • Entender por qué los fallos de trayectoria escapan a los tests por casos y a las propiedades sobre entradas aisladas.
  • Escribir un modelo de referencia y reconocer por qué su ineficiencia y su simplicidad son requisitos, no defectos.
  • Estructurar comandos con precondición, efecto sobre el modelo y comprobación sobre el sistema real, y encadenarlos con fc.modelRun.
  • Leer la traza reducida de una divergencia y delimitar qué clase de fallos esta técnica nunca encontrará.

El modelo como oráculo de referencia

Todo test necesita un oráculo: algo que sepa cuál es la respuesta correcta. Esa necesidad es tan universal que suele pasar desapercibida, pero merece nombrarse porque las tres técnicas del nivel se distinguen precisamente por dónde colocan el oráculo, y cada colocación tiene un perfil distinto de coste y de alcance. En el test por casos el oráculo es una persona, que escribió el valor esperado a mano. En una propiedad el oráculo es una ley del dominio. En el testing basado en modelos el oráculo es una segunda implementación, y de ahí viene toda su potencia y también su única exigencia seria: hay que escribirla.

Conviene también situar el momento en que la técnica compensa. Escribir un modelo no se justifica para un reducer de tres transiciones: ahí la tabla de casos lo cubre todo y el modelo sería más largo que el código. Se justifica cuando concurren dos condiciones, y basta con que concurran para que la inversión se recupere rápido: que el módulo mantenga alguna forma de estado redundante —una caché, un índice, un contador, un valor derivado almacenado— y que su interfaz ofrezca varias operaciones que se puedan combinar libremente. Redundancia más combinatoria es la firma exacta del fallo de trayectoria.

Un buen modelo tiene cualidades que en producción serían defectos y que aquí son precisamente lo que se busca. Debe ser obviamente correcto, lo que significa que su lectura debe bastar para convencer a un revisor sin necesidad de razonar; si el modelo requiere argumentación, ya no sirve de oráculo. Debe ser ineficiente sin pudor: donde la implementación real mantiene un índice para no recorrer la colección, el modelo recorre la colección entera cada vez, porque el bucle ingenuo es más fácil de auditar que el índice. Y debe ser incompleto por diseño, ya que no tiene que reproducir el sistema entero sino solo el aspecto observable que se quiere verificar: si lo que se comprueba es la aritmética del carrito, el modelo puede ignorar por completo el orden de las líneas.

// Modelo: obviamente correcto, deliberadamente lento.
type Modelo = { lineas: { id: string; precio: number; cant: number }[] }

const totalModelo = (m: Modelo) =>
  m.lineas.reduce((s, l) => s + l.precio * l.cant, 0)

// Sistema real: mantiene un total incremental por rendimiento.
// Justo el tipo de optimizacion que se desincroniza con el tiempo.
type Real = { lineas: Map<string, Linea>; totalCache: number }

El contraste del ejemplo es el caso arquetípico. El sistema real lleva un total en caché que actualiza en cada operación, porque recalcularlo en cada render sería costoso. Esa caché es correcta el día que se escribe y se desincroniza tres meses después, cuando alguien añade una operación nueva y olvida actualizarla en una de sus ramas. Ninguna revisión de código detecta eso de forma fiable. El modelo, que no tiene caché porque no tiene prisa, lo detecta a la primera secuencia que ejercite la rama olvidada.

Queda por decidir qué significa exactamente que ambos coincidan, y es una decisión de diseño con consecuencias. Comparar las dos representaciones internas campo a campo sería absurdo, porque son distintas a propósito: una usa una lista y la otra un mapa con índice. Lo que se compara es la proyección observable, es decir, el conjunto de preguntas que un consumidor del módulo puede hacerle. Elegir bien ese conjunto es la mitad del trabajo: si es demasiado pequeño, la técnica no verá nada; si incluye detalles internos, el test se romperá en cada refactor legítimo. La regla práctica es incluir exactamente lo que la interfaz pública expone y nada más.

// La proyeccion observable: lo unico que se compara paso a paso.
type Observable = { total: number; numLineas: number; ids: string[] }

const verModelo = (m: Modelo): Observable => ({
  total: totalModelo(m),
  numLineas: m.lineas.length,
  ids: m.lineas.map((l) => l.id).sort(),
})

const verReal = (r: Real): Observable => ({
  total: r.total(),
  numLineas: r.lineas.size,
  ids: [...r.lineas.keys()].sort(),
})

La ordenación de los identificadores en ambas proyecciones no es un detalle menor: expresa la decisión deliberada de que el orden interno no forma parte del contrato. Si más adelante el orden sí importase, bastaría con quitar esa ordenación y la técnica empezaría a vigilarlo. Cada decisión de ese tipo que se toma al escribir la proyección es una cláusula de la especificación que antes vivía en la cabeza de alguien.

Comandos: precondición, efecto y comprobación

La secuencia generada no es una lista de valores sino una lista de comandos, y un comando es un objeto pequeño con tres responsabilidades bien delimitadas. La precondición decide si el comando tiene sentido en el estado actual del modelo, y sirve para no generar secuencias absurdas que fallarían por motivos ajenos a lo que se quiere probar. El efecto aplica la operación sobre el modelo, actualizando la versión ingenua de la verdad. La comprobación ejecuta la operación equivalente sobre el sistema real y afirma que su estado observable sigue coincidiendo con el del modelo.

import fc from 'fast-check'

class QuitarLinea implements fc.Command<Modelo, Real> {
  constructor(readonly indice: number) {}

  // Precondicion: solo tiene sentido si hay algo que quitar
  check = (m: Readonly<Modelo>) => m.lineas.length > 0

  run(m: Modelo, r: Real): void {
    const pos = this.indice % m.lineas.length
    const id = m.lineas[pos].id
    m.lineas.splice(pos, 1) // efecto sobre el modelo
    r.quitar(id)            // misma operacion sobre el sistema real
    expect(r.total()).toBe(totalModelo(m)) // comprobacion de coherencia
  }

  toString = () => `QuitarLinea[${this.indice}]`
}

El método que devuelve una descripción textual del comando no es decorativo: es lo que hace legible la traza cuando algo falla. Un motor que informa de una secuencia de cuatro comandos anónimos obliga a reconstruir mentalmente qué ocurrió; uno que informa de una secuencia con nombres y argumentos entrega el caso de reproducción listo para pegar en un test de regresión. Invertir dos minutos en esas descripciones ahorra horas en el primer fallo real.

flowchart TD
GEN[Generador de secuencias de comandos] --> C1[Comando 1]
C1 --> M1[Aplica al modelo ingenuo]
C1 --> R1[Aplica al sistema real]
M1 --> CMP1[Comparar estado observable]
R1 --> CMP1
CMP1 -->|coinciden| C2[Comando 2]
CMP1 -->|divergen| FALLO[Primera divergencia detectada]
C2 --> CN[Comando N]
FALLO --> RED[Reduccion a la traza minima]
RED --> INF[Informe: secuencia mas corta que rompe]
style GEN fill:#cba6f7,color:#11111b
style FALLO fill:#f38ba8,color:#11111b
style INF fill:#a6e3a1,color:#11111b

El encadenado lo hace fc.modelRun, que recibe la fábrica del par modelo y sistema real y la secuencia de comandos generada, y los ejecuta en paralelo respetando las precondiciones. Combinado con fc.commands, que produce secuencias aleatorias a partir de la lista de comandos disponibles, el resultado es un test de una decena de líneas que explora un espacio de trayectorias inalcanzable a mano.

test('el sistema real coincide con el modelo en toda secuencia', () => {
  const comandos = [
    fc.integer({ min: 0, max: 20 }).map((i) => new QuitarLinea(i)),
    arbLinea.map((l) => new AnadirLinea(l)),
    fc.constant(new VaciarCarrito()),
  ]
  fc.assert(
    fc.property(fc.commands(comandos, { maxCommands: 30 }), (secuencia) => {
      fc.modelRun(() => ({ model: modeloVacio(), real: realVacio() }), secuencia)
    }),
    { numRuns: 500 },
  )
})

Lo que la técnica encuentra y lo que jamás verá

Conviene ser preciso sobre el alcance, porque el entusiasmo con esta técnica suele degenerar en expectativas que no puede cumplir. Lo que encuentra con una eficacia casi incómoda son tres familias: las cachés y los índices desincronizados, que es el caso arquetípico; las transiciones olvidadas, cuando un comando nuevo no actualiza un campo derivado en alguna rama; y los efectos de orden, cuando dos operaciones que deberían conmutar resulta que no conmutan.

Hay un cuarto beneficio menos obvio y que se aprecia con el tiempo: la técnica detecta las regresiones de refactor con una precisión que ningún otro test alcanza. Cuando alguien reescribe el módulo real para hacerlo más rápido, el modelo sigue siendo el mismo y la suite compara la versión nueva contra la especificación original, no contra la implementación anterior. Es la única configuración en la que se puede reescribir por completo un componente de estado y saber, con evidencia y no con fe, que el comportamiento observable no cambió.

Lo que jamás encontrará es el error compartido. Si el modelo y la implementación real interpretan mal la misma regla de negocio —porque los escribió la misma persona con el mismo malentendido— ambos coincidirán perfectamente en su error y la suite quedará verde para siempre. Ese es el límite duro de todo oráculo por comparación, y por eso el testing basado en modelos no sustituye a la tabla de casos escrita a mano, que sí ancla el comportamiento a una decisión humana explícita. Tampoco verá nada que no esté en el estado observable que decidiste comparar, ni nada relacionado con el rendimiento, la concurrencia real o la interfaz.

⚠️
Si escribir el modelo es difícil, el problema está en el sistema

Cuando el modelo empieza a necesitar sus propias cachés, sus propias condiciones especiales o sus propias ramas oscuras para seguirle el ritmo a la implementación real, algo se ha torcido, y casi nunca es el modelo. Ese síntoma indica que la implementación real ha acumulado complejidad accidental que no responde a ninguna necesidad del dominio, o que el estado observable elegido para comparar mezcla lo esencial con detalles internos. La reacción correcta no es complicar el modelo hasta que empate, sino usar la dificultad como diagnóstico: el modelo es la versión honesta de lo que tu sistema debería ser, y toda distancia entre ambos es una deuda que alguien tendrá que explicar.

Escribir el sistema dos veces es más barato que depurarlo una

Cuesta aceptar la propuesta porque suena a derroche: si ya tengo el sistema, escribirlo otra vez es duplicar el trabajo. Esa objeción parece de sentido común y es exactamente al revés, por una razón que se aprecia en cuanto se traduce a economía real. El modelo no es una copia del sistema, es su especificación escrita en un lenguaje ejecutable. La implementación real está llena de decisiones que no pertenecen al dominio sino al rendimiento y a la plataforma —índices para no recorrer, totales cacheados para no recalcular, estructuras planas para no navegar, memoización para no repetir— y cada una de esas decisiones es una oportunidad de que la verdad se bifurque en dos versiones que van separándose en silencio durante meses. El modelo es el lugar donde el dominio se escribe sin ninguna de esas concesiones, y por eso escribirlo tiene un efecto secundario que muchas veces supera al propio testing: te obliga a enunciar, sin escapatoria, cuál es el comportamiento correcto con independencia de cómo lo implementaste. Muchos equipos descubren durante esa redacción que no había una única respuesta a esa pregunta, sino tres respuestas distintas conviviendo en la cabeza de tres personas distintas, y esa revelación vale por sí sola el precio del ejercicio. Hay además una asimetría de coste que decide el argumento. El modelo se escribe una vez, en calma, con la cabeza fría, en una tarde. El fallo de trayectoria que el modelo detecta se depura, si no se detecta, en producción, de noche, con datos corruptos y un usuario esperando, sin poder reproducirlo porque nadie sabe qué secuencia de clics lo provocó. Comparar esas dos tardes es todo el análisis coste-beneficio que hace falta. Y hay una tercera ganancia que casi nadie anticipa: el modelo sobrevive a la reescritura. Cuando dentro de dos años cambies de librería de estado, de framework o de lenguaje, la implementación real se tira entera y el modelo se queda intacto, porque describe el dominio y no la tecnología. Es, en el sentido más literal, la parte de tu código que no caduca.

⚔️ Duplica un módulo a propósito y busca la divergencia
  1. Elige el módulo de estado de tu proyecto que más optimizaciones tenga —cachés, índices, valores derivados almacenados— porque es donde la técnica rinde más.
  2. Escribe el modelo ingenuo equivalente sin ninguna de esas optimizaciones, recorriendo colecciones enteras siempre que haga falta, y verifica su corrección leyéndolo en voz alta.
  3. Define al menos cuatro comandos con su precondición, su efecto sobre el modelo y su comprobación contra el sistema real, con descripciones textuales legibles.
  4. Lanza quinientas secuencias de hasta treinta comandos con fc.commands y fc.modelRun y anota si aparece alguna divergencia.
  5. Si no aparece ninguna, introduce a propósito un olvido en la actualización de una caché en una sola rama y comprueba cuántos comandos necesita la traza mínima para exhibirlo.
  6. Escribe un párrafo explicando qué clase de fallo de tu módulo esta técnica nunca detectaría, y qué test por casos habría que escribir a mano para cubrirlo.