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

Property-based testing: afirmar invariantes

El test por casos comprueba lo que a quien lo escribió se le ocurrió comprobar, y por eso su punto ciego coincide exactamente con el punto ciego del autor: los fallos de una aplicación viven casi siempre en las entradas que nadie imaginó. El testing basado en propiedades invierte la figura. En lugar de fijar una entrada y afirmar una salida concreta, se declara una afirmación universal sobre todas las entradas posibles —el total nunca es negativo, deshacer y rehacer devuelve al mismo estado, el orden de dos acciones independientes no altera el resultado— y se deja que un generador produzca cientos o miles de entradas buscando un contraejemplo. Esta lección introduce el vocabulario preciso de la técnica —property, arbitrary, generador, contraejemplo, reducción— y lo aterriza en fast-check, la implementación de referencia en el ecosistema de JavaScript y TypeScript. Se muestra cómo se escribe una propiedad sobre un reducer, cómo se componen arbitraries para generar estados y acciones válidos, y por qué la reducción automática del contraejemplo es la característica que convierte un fallo aleatorio ilegible en un caso mínimo que cabe en una línea. Se cierra con el aspecto que decide el éxito de la técnica en la práctica: aprender a formular invariantes que sean a la vez verdaderas y no triviales, y reconocer la propiedad tautológica que no comprueba nada.

⏱ 22 min

Un test por casos tiene una limitación estructural que ninguna cantidad de disciplina corrige: comprueba lo que su autor imaginó. La tabla de la lección anterior es densa, legible y valiosa, pero cada una de sus filas nació en la cabeza de alguien, y los fallos que llegan a producción son precisamente los que no estaban en ninguna cabeza: la lista con cero elementos, el número justo en el límite, la cadena vacía donde se esperaba un nombre, la secuencia de tres acciones que nadie ejecutaría a mano pero que un usuario impaciente produce en dos segundos. El testing basado en propiedades ataca ese punto ciego cambiando la unidad de afirmación. Deja de decir para esta entrada concreta espero esta salida concreta, y pasa a decir para cualquier entrada válida se cumple esta relación. Esa segunda frase es una afirmación universal, y a diferencia de la primera no depende de la imaginación de quien la escribe sino de su comprensión del dominio. Después, un motor genera entradas —muchas, raras, extremas, deliberadamente hostiles— y busca activamente romperla. Cuando lo consigue no te entrega el caso monstruoso que la rompió, sino la versión más pequeña de ese caso que sigue rompiéndola, y esa versión mínima suele ser tan clara que el diagnóstico es inmediato. La técnica no sustituye a la tabla de casos: la complementa cubriendo el espacio que la tabla, por definición, no puede cubrir.

🎯 Al terminar esta lección sabrás
  • Distinguir la afirmación existencial del test por casos de la afirmación universal de una property, y saber cuándo cada una es la herramienta adecuada.
  • Escribir propiedades sobre lógica de estado con fast-check, componiendo arbitraries que generen entradas válidas del dominio.
  • Entender la reducción automática del contraejemplo y por qué convierte un fallo aleatorio en un diagnóstico legible.
  • Formular invariantes útiles y detectar la propiedad tautológica que reimplementa la lógica y no comprueba nada.

De la salida concreta al invariante universal

Conviene fijar el vocabulario porque en esta técnica cada término tiene un sentido técnico estrecho. Una property es una afirmación que debe cumplirse para todas las entradas de un cierto conjunto; no es un ejemplo, es una ley. Un arbitrary es la descripción de ese conjunto en forma de generador: un objeto que sabe producir valores aleatorios del tipo que le corresponde y, crucialmente, sabe también producir versiones más pequeñas de un valor dado. El motor toma la propiedad y el arbitrary, ejecuta la afirmación un número configurable de veces con valores distintos y, si alguna falla, entra en la fase de reducción para simplificar el culpable.

La diferencia lógica entre ambos géneros de test se puede enunciar con precisión y merece la pena hacerlo, porque de ella se derivan todas las consecuencias prácticas. Un test por casos es una afirmación existencial verificada: dice que existe al menos una entrada, la que yo elegí, para la cual el sistema se comporta como espero. Una propiedad es una afirmación universal sometida a refutación: dice que para toda entrada del conjunto se cumple una relación, y el motor no intenta confirmarla —confirmarla exigiría recorrer un espacio infinito— sino refutarla buscando un solo contraejemplo. Esa asimetría es exactamente la que describió Popper para las teorías científicas, y tiene la misma moraleja: la propiedad nunca queda demostrada, pero cada campaña que sobrevive aumenta la confianza, y una sola entrada que la rompa la derriba sin apelación.

El cambio de mentalidad más difícil no es sintáctico sino epistemológico: hay que dejar de pensar en resultados y empezar a pensar en relaciones. Un test por casos afirma un valor de salida; una propiedad afirma algo que relaciona la entrada con la salida, o dos salidas entre sí, sin necesidad de conocer ninguna de las dos. Las familias de relaciones que resultan útiles son pocas y se repiten en todos los dominios, así que aprenderlas de memoria acelera enormemente la escritura.

🛡️

Invariantes de estado

Algo que es cierto en todo estado alcanzable, pase lo que pase. El total nunca es negativo, la lista de seleccionados es siempre un subconjunto de la lista visible, no hay dos elementos con el mismo identificador. Es la familia más valiosa en lógica de estado porque describe el dominio, no la implementación.

🔁

Ida y vuelta

Aplicar una operación y su inversa devuelve al punto de partida. Serializar y deserializar, codificar y decodificar en la URL, deshacer después de hacer, comprimir y descomprimir. Detecta con una sola línea toda una clase de errores de simetría.

🔀

Conmutación e idempotencia

Dos acciones independientes dan el mismo resultado en cualquier orden; aplicar dos veces la misma acción equivale a aplicarla una vez. Son las leyes que gobiernan las condiciones de carrera y los reintentos, y casi nadie las comprueba con casos sueltos.

⚖️

Oráculo alternativo

Comparar el resultado contra una implementación obviamente correcta aunque sea lenta, o contra la versión anterior del algoritmo al optimizarlo. Si la implementación rápida y la ingenua discrepan en alguna entrada, tienes el caso servido.

Escribir propiedades con fast-check

fast-check expone una API compacta que se aprende en una tarde. fc.assert ejecuta la campaña, fc.property empareja uno o más arbitraries con un predicado, y el módulo ofrece constructores para los tipos primitivos que se combinan para describir estructuras arbitrariamente complejas. El siguiente ejemplo comprueba el invariante más citado del catálogo sobre un carrito de compra.

import fc from 'fast-check'

const arbLinea = fc.record({
  id: fc.uuid(),
  precio: fc.integer({ min: 0, max: 100_000 }),
  cantidad: fc.integer({ min: 1, max: 99 }),
})

const arbEvento = fc.oneof(
  arbLinea.map((linea) => ({ tipo: 'ANADIR' as const, linea })),
  fc.uuid().map((id) => ({ tipo: 'QUITAR' as const, id })),
  fc.integer({ min: 0, max: 100 }).map((pct) => ({ tipo: 'DESCUENTO' as const, pct })),
)

test('el total nunca es negativo, haga lo que haga el usuario', () => {
  fc.assert(
    fc.property(fc.array(arbEvento, { maxLength: 40 }), (eventos) => {
      const final = eventos.reduce(reducer, estadoInicial)
      return total(final) >= 0
    }),
    { numRuns: 1_000 },
  )
})

Merece la pena leer el generador de arriba abajo antes que el predicado, porque en él está la mitad del rigor. Se describe una línea de carrito con identificador válido, precio no negativo acotado y cantidad de al menos uno; se describe un evento como una elección entre tres formas posibles; y se describe la entrada de la propiedad como una lista de hasta cuarenta de esos eventos. Esa lista es el espacio de trayectorias que la campaña recorrerá, y su forma decide qué puede y qué no puede encontrarse. Ampliar el máximo de eventos explora secuencias más largas; permitir precios negativos exploraría un mundo que el sistema real nunca produce y generaría ruido.

Fíjate en lo que ese test dice y en lo que no dice. No afirma cuánto vale el total en ningún escenario; afirma que hay una región del espacio de valores donde el total no puede caer, para cualquier secuencia de hasta cuarenta eventos de cualquier tipo, en cualquier orden. Ninguna tabla de casos razonable habría probado la secuencia que consiste en aplicar un descuento del cien por cien, quitar una línea inexistente y volver a añadir otra, y sin embargo ese es justamente el tipo de trayectoria en la que aparecen los errores de aritmética de dominio.

La composición de arbitraries es la parte que exige oficio. Un generador demasiado permisivo produce entradas que violan precondiciones del dominio y genera falsos positivos ruidosos; un generador demasiado estrecho no explora nada interesante y da una falsa sensación de seguridad. La disciplina consiste en construir el arbitrary para que produzca exactamente lo que el sistema real puede producir: si un identificador siempre existe porque lo emite el servidor, genera identificadores válidos; si una cantidad nunca es cero por invariante de la interfaz, no la generes cero, o el fallo que encuentres será sobre un escenario imposible.

Hay dos maneras de restringir un generador y no son intercambiables. Se puede filtrar, descartando los valores que no cumplen una condición, o se puede construir, generando directamente solo valores válidos. Filtrar es cómodo y es casi siempre la peor opción: si la condición es rara, el motor descarta la mayoría de lo que produce, la campaña se vuelve lenta y, sobre todo, la reducción del contraejemplo se degrada porque muchas de las versiones simplificadas que intenta caen fuera del filtro. Construir cuesta un poco más de pensamiento y produce campañas rápidas con reducciones limpias.

// Mal: filtrar. Descarta la mayoria y estropea la reduccion.
const arbMalo = fc.tuple(fc.integer(), fc.integer()).filter(([a, b]) => a < b)

// Bien: construir. Todo lo generado es valido por construccion.
const arbBueno = fc
  .tuple(fc.integer({ min: 0, max: 1_000 }), fc.nat({ max: 1_000 }))
  .map(([inicio, ancho]) => [inicio, inicio + ancho + 1] as const)

El principio general es que toda precondición que puedas mover del predicado al generador es una precondición que deja de costar tiempo de ejecución y deja de ensuciar el diagnóstico. Cuando no haya más remedio que expresarla como condición, fast-check permite además declararla como suposición dentro de la propiedad, de modo que el caso se marque como descartado en vez de como fallido, y el informe final avise si el porcentaje de descartes es tan alto que la campaña no ha explorado nada.

Reducción, semillas y el arte de no ser tautológico

La característica que hace utilizable la técnica no es la generación aleatoria —eso es fácil— sino la reducción, o shrinking. Cuando una propiedad falla, el valor culpable suele ser un monstruo: una lista de treinta y siete eventos con identificadores aleatorios de treinta y seis caracteres. Depurar eso a mano sería inviable. El motor entonces vuelve a intentarlo con versiones progresivamente más pequeñas del mismo valor —menos elementos, números más cercanos a cero, cadenas más cortas— y conserva la más pequeña que sigue fallando. El resultado que te presenta suele caber en una línea y a menudo el diagnóstico es evidente con solo leerlo.

flowchart TD
P[Propiedad universal] --> G[Generador produce entradas]
G --> E[Ejecuta el predicado N veces]
E -->|todas pasan| OK[Sin contraejemplo en N intentos]
E -->|una falla| S[Reduccion del contraejemplo]
S --> S2[Prueba versiones mas pequenas]
S2 -->|sigue fallando| S2
S2 -->|deja de fallar| MIN[Contraejemplo minimo y semilla]
MIN --> R[Test de regresion por caso fijo]
style P fill:#cba6f7,color:#11111b
style MIN fill:#f38ba8,color:#11111b
style R fill:#a6e3a1,color:#11111b

Junto al contraejemplo mínimo, el motor imprime la semilla y el camino que llevaron a él. Esos dos números permiten reproducir la ejecución exacta, y son la respuesta a la objeción habitual de que un test aleatorio es un test no determinista. No lo es: es determinista dada la semilla, y el flujo de trabajo correcto cuando falla consiste en fijar la semilla para depurar, arreglar el fallo y después congelar el contraejemplo mínimo como un caso normal en la tabla de la lección anterior, para que esa regresión concreta quede protegida sin depender del azar.

Esa política de dos velocidades resuelve también la tensión práctica entre exhaustividad y tiempo de espera. Una campaña de mil ejecuciones sobre secuencias de cuarenta eventos no cabe en el ciclo de trabajo de quien está escribiendo código y quiere retroalimentación en menos de un segundo. La configuración razonable en la mayoría de los equipos usa un número modesto de ejecuciones en local, un número alto en la integración continua y una campaña larga programada por la noche con semilla libre, cuyos hallazgos se convierten a la mañana siguiente en casos fijos. Las propiedades exploran; los casos fijos custodian lo ya explorado. Confundir ambos papeles produce o suites lentísimas o regresiones que reaparecen porque nadie las ancló.

Hay, por último, una limitación epistemológica que conviene enunciar sin adornos, porque distingue a quien entiende la técnica de quien la aplica por moda. Una campaña que pasa no demuestra que la propiedad sea cierta: demuestra que el motor no encontró un contraejemplo con esa semilla, ese generador y ese número de intentos. Es evidencia, no prueba. La distinción importa porque señala dónde invertir el esfuerzo cuando la garantía debe ser fuerte: en enunciar mejor la propiedad y en ampliar el arbitrary hacia las regiones raras del espacio, no en subir el número de ejecuciones, que tiene rendimientos rápidamente decrecientes.

💡
La propiedad tautológica es el error de principiante

La trampa más común al empezar es escribir una propiedad que reimplementa la lógica que pretende comprobar. Si la propiedad dice que el total devuelto es igual a la suma de precio por cantidad de cada línea, y el reducer calcula el total como la suma de precio por cantidad de cada línea, la afirmación es una tautología: pasará siempre, incluso con la fórmula equivocada, porque las dos copias del error coinciden. Una propiedad útil habla de relaciones que se pueden enunciar sin conocer el algoritmo: es monótona, es no negativa, es reversible, no depende del orden, coincide con una implementación ingenua independiente. Si al leer tu propiedad en voz alta suena como una descripción del código en vez de como una ley del dominio, bórrala y empieza otra vez.

El catálogo aplicado a la lógica de estado

Conviene terminar con propiedades concretas sobre reducers, porque el catálogo abstracto se vuelve operativo en cuanto se ve instanciado. Las cuatro siguientes cubren, entre las cuatro, una parte desproporcionada de los errores reales de un módulo de estado, y ninguna de ellas requiere conocer el algoritmo interno.

// 1. Invariante de dominio: ninguna secuencia alcanza un estado prohibido
fc.assert(fc.property(arbSecuencia, (evs) => {
  const s = evs.reduce(reducer, inicial)
  return s.seleccionados.every((id) => s.visibles.includes(id))
}))

// 2. Ida y vuelta: serializar el estado y recuperarlo no pierde informacion
fc.assert(fc.property(arbEstado, (s) => {
  expect(deserializar(serializar(s))).toEqual(s)
}))

// 3. Idempotencia: repetir un evento no acumula efecto
fc.assert(fc.property(arbEstado, arbEventoIdempotente, (s, e) => {
  expect(reducer(reducer(s, e), e)).toEqual(reducer(s, e))
}))

// 4. Conmutacion: dos eventos independientes dan lo mismo en cualquier orden
fc.assert(fc.property(arbEstado, arbEvA, arbEvB, (s, a, b) => {
  expect(reducer(reducer(s, a), b)).toEqual(reducer(reducer(s, b), a))
}))

La tercera y la cuarta merecen un comentario porque son las que más sorpresas dan. La idempotencia es la ley que gobierna los reintentos: si un evento no es idempotente y la red reenvía un mensaje duplicado, el estado se corrompe, y ese escenario ocurre en producción con una frecuencia que los equipos subestiman sistemáticamente. La conmutación es la ley que gobierna las condiciones de carrera: dos respuestas que llegan en orden invertido producen estados distintos, y comprobarlo con una propiedad cuesta cuatro líneas frente a las horas que cuesta reproducir la carrera a mano.

Dejas de dar ejemplos y empiezas a enunciar leyes

El salto que propone esta técnica no es de herramienta, es de género literario. Un test por casos pertenece al género del ejemplo: describe una anécdota concreta y confía en que sea representativa de una familia que nunca se enuncia. Una propiedad pertenece al género de la ley: enuncia la familia entera y delega en una máquina la tarea, mecánica y aburrida, de buscar el contraejemplo. Ese cambio tiene tres consecuencias que cuesta apreciar hasta que se han vivido. La primera es que la calidad del test deja de depender de la creatividad de quien lo escribe y pasa a depender de su comprensión del dominio, que es una cualidad mucho más estable y mucho más transferible: dos ingenieros con la misma comprensión escriben la misma propiedad, mientras que dos ingenieros con la misma creatividad escriben casos distintos y ambos incompletos. La segunda es que la propiedad es documentación en un sentido en que el ejemplo nunca lo es. Leer la tabla de casos te dice qué hace el sistema en tres situaciones; leer que el total nunca es negativo y que deshacer revierte hacer te dice qué es el sistema, cuáles son las verdades que su autor se comprometió a mantener. Es una especificación parcial, escrita en un lenguaje ejecutable, que no puede desactualizarse porque la suite falla si alguien la contradice. La tercera, y la más subversiva, es que escribir propiedades te obliga a descubrir cuáles son tus invariantes, y esa es una pregunta que la mayoría de los equipos jamás se ha planteado explícitamente. Con enorme frecuencia el mayor valor de una sesión de testing basado en propiedades no está en el fallo que encuentra el generador, sino en la conversación incómoda que provoca la pregunta previa: ¿puede este total ser negativo alguna vez? Nadie lo sabía. Ahora hay que decidirlo, escribirlo y defenderlo mil veces por ejecución. Eso ya no es control de calidad, es diseño.

⚔️ Enuncia las leyes de tu propio dominio
  1. Escribe en lenguaje natural cinco afirmaciones que creas verdaderas para todo estado alcanzable de tu aplicación, sin mirar el código ni una sola vez.
  2. Clasifica cada una en las familias del catálogo: invariante, ida y vuelta, conmutación, idempotencia u oráculo alternativo.
  3. Instala fast-check y traduce la más sencilla a una property con al menos mil ejecuciones. Observa si aguanta.
  4. Construye un arbitrary compuesto que genere secuencias de eventos válidos de tu dominio y aplícalo a la propiedad del total no negativo.
  5. Introduce a propósito un error de signo en tu reducer, deja que el motor lo encuentre y anota el tamaño del contraejemplo antes y después de la reducción.
  6. Congela ese contraejemplo mínimo como caso fijo en tu tabla de la lección anterior y explica por qué conviene tener ambas cosas y no solo una.