skip to content

Property testing con eris: el bug que nadie escribió

8 min read

Cómo el property-based testing destapó un bug latente en el cálculo de impuestos de un ERP —un bug que ningún test de ejemplo iba a tocar nunca— y cómo lo integramos al flujo de trabajo en dos stacks distintos: PHP con eris, y TypeScript y Python en el data lake.

Hay una clase de bug que tus pruebas nunca van a encontrar, no porque sean malas, sino porque están escritas con la misma cabeza que escribió el código. Si el autor entendió mal una regla, la entiende mal en la implementación y en el test que la verifica. Verde en ambos lados, error en producción.

Hace unas semanas, endureciendo el módulo fiscal de un ERP contable, me topé con uno de esos bugs. No lo estaba buscando. Lo encontró una técnica que llevaba tiempo queriendo probar en serio: el property-based testing. Esta es la historia de qué aportó, qué no aportó, y cómo terminó integrado en el flujo de trabajo de dos proyectos a la vez.

El ejemplo contra la ley

Un test tradicional afirma un caso puntual: “para una factura de 100 con IVA del 12%, el total es 112”. Tú eliges los números. El problema es que solo verificas los casos que se te ocurrieron.

El property-based testing invierte la relación. En lugar de dar un caso, declaras una ley que debe cumplirse para cualquier entrada, y la herramienta genera cientos de casos buscando uno que la rompa. En PHP esa herramienta es eris.

Un ejemplo real, sobre una función pura que reparte un monto en cuotas iguales:

use Eris\Generator;
use Eris\TestTrait;
 
uses(TestTrait::class);
 
it('reparte un total sin perder un centavo, para cualquier monto y plazo', function () {
    $this->forAll(
        Generator\choose(1, 100_000_00), // monto en centavos: 0.01 .. 1,000,000.00
        Generator\choose(1, 120),        // número de cuotas
    )->then(function (int $cents, int $periods) {
        $total  = bcdiv((string) $cents, '100', 2);
        $cuotas = (new StraightLineAmortizer)->amortize($total, $periods, 2026, 1);
 
        // LEY: la suma de las cuotas es EXACTAMENTE el total. Sin drift de redondeo.
        $suma = array_reduce($cuotas, fn ($a, $c) => bcadd($a, $c['amount'], 2), '0.00');
        expect(bccomp($suma, $total, 2))->toBe(0);
    });
});

No escribí ni un solo caso. Escribí la ley —“la suma de las cuotas iguala el total”— y eris probó cientos de combinaciones de monto y plazo. Lo más valioso aparece cuando algo falla: eris hace shrinking, reduce el caso que rompe la ley a su versión mínima reproducible (por ejemplo, total=3.00, cuotas=3) y te entrega un ERIS_SEED para reproducirlo de forma determinística. En lugar de “falló con 84729 y 47 cuotas”, te da el contraejemplo más pequeño posible, masticado.

El caso real: dos calculadoras que no se hablaban

El ERP calcula el ISR (impuesto sobre la renta) guatemalteco, que es progresivo por tramos. Y tenía dos clases distintas calculando esos tramos, escritas en momentos distintos:

  • La retención usaba un modelo absoluto: por cada tramo, grava la porción de la base entre el piso del tramo y el techo, porción = min(base, techo) − piso.
  • El ISR mensual usaba un modelo de consumo: iba “gastando” la base contra el ancho de cada tramo, cap = techo − consumido, y nunca leía el piso del tramo.

Para los datos reales de Guatemala, donde el primer tramo arranca en cero, las dos daban el mismo resultado. Pero pensé en una tabla de tramos con una banda exenta inicial —el primer tramo empieza, digamos, en 1000— y los números divergieron:

Con tramos [1000, 2000) al 5% y [2000, ∞) al 7%, sobre una base de 1500:

  • Correcto (marginal): solo 500 caen en el tramo gravado → 500 × 5% = 25.00.
  • ISR mensual: gravaba 1500 × 5% = 75.00, ignorando los primeros 1000 que debían estar exentos.

Veinticinco contra setenta y cinco. El modelo de consumo daba por hecho que los tramos siempre arrancan en cero. Es un supuesto de país disfrazado de código: cierto para Guatemala hoy, falso en cuanto extiendas el catálogo a otra jurisdicción con un mínimo exento, exactamente la clase de extensibilidad que el producto persigue.

Lo importante, y hay que ser honesto: el bug era latente. Con los datos en producción nunca disparó, porque las tablas reales empiezan en cero. Ningún test de ejemplo lo iba a encontrar nunca, porque todos los ejemplos usaban esas mismas tablas. El bug vivía en el hueco entre lo que el código asumía y lo que el código prometía.

El arreglo fue unificar: una sola función pura, ProgressiveBracketCalculator, que respeta el piso del tramo y es independiente del orden, consumida por ambas calculadoras. Y las properties quedaron de guardia para que no vuelva a divergir.

La lección incómoda

Antes de declarar victoria, probé el mismo ciclo en módulos maduros y bien probados —ventas, periodificación— con varias calculadoras de dinero. Resultado: eris en verde, cero bugs. Sumé mutation testing (que mete errores deliberados en el código y mide si los tests los atrapan) y el puntaje fue de un 66% engañoso: casi todos los “sobrevivientes” eran mutantes equivalentes —cambiar la escala de una operación BCMath de 2 a 3 decimales sobre valores que ya tienen 2 decimales no cambia el resultado, así que ningún test puede matarlos—. Ruido, no huecos.

De ahí salió la tesis que más me sirvió:

El valor del property testing es inversamente proporcional a la calidad de los tests que ya tienes.

En código maduro y bien cubierto, confirma lo que ya sabías (y eso también vale: te da confianza sobre cientos de casos en vez de cinco). En código nuevo o muy ramificado —tramos, regímenes, reglas por país, redondeos— es donde caza bugs de verdad. No lo apuntes a tu CRUD; apúntalo a tu lógica de dinero.

El disparador: cuándo escribir una property

La regla práctica, la que de verdad importa, es poder completar esta frase:

“Para CUALQUIER entrada, ____ debe cumplirse.”

Si puedes nombrar el invariante, es candidato. Si no, quédate con tests de ejemplo. Las leyes que más se repiten:

  • Conservación: Σ partes == total (dinero en BCMath, nunca float).
  • Monotonía: más base ⇒ el impuesto nunca baja.
  • Idempotencia: f(f(x)) == f(x).
  • Round-trip: deshacer lo hecho vuelve al estado original (anular(postear(x)) deja los saldos como antes).
  • Cotas: 0 ≤ resultado ≤ límite.
  • Independencia del orden: reordenar la entrada no cambia el resultado.

Y una condición no negociable: solo funciones puras. Si la cosa necesita base de datos para correr, el property testing no es la herramienta (y además se vuelve lento). Por suerte, una arquitectura sana ya empuja la lógica de dinero hacia servicios puros que reciben arreglos o DTOs.

Cómo lo integramos al flujo (sin fricción)

La parte difícil del property testing no es el cómo —el patrón son treinta líneas—, es el cuándo. Así que lo integramos en tres capas, sin maquinaria nueva:

  1. Convención escrita. El disparador (“para cualquier input, ___”), las funciones puras como único blanco, y el caveat de los mutantes equivalentes quedaron documentados en las convenciones de testing del repo. Toda persona —o agente— que entre al proyecto lo ve.
  2. En el ciclo. En el paso de TDD, si la etapa produce una función pura con una ley, se escribe la property junto a los ejemplos, en el momento. Las pruebas de propiedad son tests normales: el corredor de tests ya las descubre y las corre, cero infraestructura adicional.
  3. En el generador. El proyecto genera módulos nuevos desde un manifiesto. Ahora ese generador siembra un stub de property test en cada módulo —un esqueleto comentado con la lista de leyes— para que escribir la primera propiedad sea renombrar un archivo, no recordar una práctica.

El mutation testing quedó como auditoría puntual, una vez por núcleo nuevo, nunca como gate de integración continua: el ruido de los mutantes equivalentes haría más mal que bien si bloqueara cada commit.

El mismo concepto, en ambos stacks

El property testing no es de PHP; es una idea portable. Solo cambia la herramienta según el stack. Lo llevamos a los dos frentes de trabajo:

En el ERP (PHP / Laravel), eris, como ya vimos.

En el portal y el data lake (TypeScript / Nuxt), fast-check:

import fc from "fast-check";
 
test("el total de la factura conserva los centavos", () => {
  fc.assert(
    fc.property(
      fc.array(fc.record({ qty: fc.nat(1000), priceCents: fc.nat(10_000_00) })),
      (lines) => {
        const totals = calcTotals(lines);
        // LEY: el subtotal es exactamente la suma de las líneas.
        expect(sumLineTotals(totals)).toBe(totals.subtotal);
      },
    ),
  );
});

En las funciones Python sobre Lambda del data lake, Hypothesis:

from hypothesis import given, strategies as st
 
@given(st.lists(st.integers(min_value=0, max_value=10_000)))
def test_el_margen_nunca_supera_el_ingreso(amounts):
    result = compute_margin(amounts)
    # LEY: el margen vive entre cero y el ingreso total, para cualquier conjunto.
    assert 0 <= result <= sum(amounts)

Tres lenguajes, la misma disciplina: declarar la ley, dejar que la máquina busque el contraejemplo.

Cuándo sí, cuándo no

  • Sí: lógica de dinero, cálculos ramificados (tramos, reglas por país, redondeos cambiarios), código greenfield, refactors de algoritmos.
  • No: CRUD, código que solo orquesta la base de datos, y como gate de CI sin antes filtrar los mutantes equivalentes.

La mejor parte de toda la experiencia no fue cazar el bug. Fue darme cuenta de que era un bug de los que ningún ejemplo iba a tocar: vivía en el espacio entre lo que el código asumía y lo que prometía. El property testing no reemplaza tus pruebas; las completa justo donde tu imaginación —y la de quien escribió el código— no llega.