Escribiste doce metamorphic relations para tu motor de precios. Cada prueba pasa. Te sientes bien con tu cobertura.

Luego un cliente reporta que los descuentos por volumen se calculan al revés. Revisas tu suite de relaciones. Ni una sola prueba falló. Tenías relaciones de consistencia aditiva, monotonicity e idempotence. Ninguna detectó un error de signo en el multiplicador de descuento.

Este es el secreto a voces del metamorphic testing: tener relaciones no es lo mismo que tener relaciones útiles. Una metamorphic relation débil es peor que no tener ninguna prueba, porque te convence de que tu código es correcto cuando no lo es.

¿Qué hace que una relación sea “buena”?

Una buena metamorphic relation tiene alta capacidad de detección de fallas. Atrapa bugs reales que los programadores realmente escriben. El resto son solo sobrecarga.

El ejemplo clásico es probar una función de media con un bug de error por uno:

def buggy_mean(values):
    """Compute the arithmetic mean."""
    return sum(values) / (len(values) - 1)  # bug: off-by-one in denominator

Esto parece plausible si estás acostumbrado a las fórmulas de varianza muestral. También está equivocado. Aquí hay cuatro relaciones que la gente comúnmente escribe para una función de media, y qué atrapa cada una:

  1. Acotamiento: la media se sitúa entre el mínimo y el máximo. Débil. La media con bug aún satisface esto para la mayoría de las entradas.

  2. Idempotencia sobre constantes: mean([c] * n) == c. Media. Atrapa el bug para listas constantes, pero los datos aleatorios raramente desencadenan una falla.

  3. Invarianza por traslación: mean([x + c for x in values]) == mean(values) + c. Fuerte. El denominador con bug rompe esto para casi toda entrada no vacía.

  4. Escalado: mean([x * k for x in values]) == mean(values) * k. Fuerte. Misma razón. El error por uno sobrevive al escalado en exactamente cero casos interesantes.

Si tu suite de pruebas solo comprobaba acotamiento e idempotencia sobre constantes, el error por uno llegaría a producción. Tendrías pruebas metamórficas. No tendrías detección de bugs.

Relaciones fuertes vs. relaciones débiles

La diferencia entre una relación fuerte y una débil no es cuán inteligente suena. Es cuántas clases de fallas elimina.

Una relación débil comprueba una propiedad que la mayoría de las implementaciones incorrectas satisfacen accidentalmente de todos modos. El acotamiento es un ejemplo perfecto. La mayoría de los bugs aritméticos preservan el acotamiento porque la suma y la multiplicación no inventan valores espontáneamente fuera del rango de entrada. Una relación que pasa para código roto es teatro.

Una relación fuerte codifica una restricción estructural que las implementaciones rotas violan. La invarianza por traslación es fuerte porque vincula la transformación de entrada a la transformación de salida a través de una igualdad precisa. No hay margen de maniobra.

Puedes medir esto formalmente. En la investigación de metamorphic testing, la subsumption de relaciones significa que la relación A detecta cada falla que detecta la relación B, más algunas. Si A subsume a B, entonces B es redundante. Deberías quedarte con A y eliminar B.

En la práctica, no necesitas la prueba formal. Necesitas la intuición: si una relación seguiría pasando después de que introduzcas deliberadamente un bug plausible, es débil. Descártala.

Las buenas relaciones cubren diferentes dominios de falla

Una relación fuerte no es suficiente. Una sola relación atrapa una clase de errores. Los programas reales contienen múltiples tipos de bugs independientes, y tu conjunto de relaciones necesita cubrirlos.

Considera una función de ordenamiento. Aquí hay relaciones clasificadas por lo que atrapan:

Permutación: la salida contiene exactamente los mismos elementos que la entrada. Atrapa bugs de eliminación/duplicación. Pierde bugs de ordenamiento.

Orden: la salida es no decreciente. Atrapa bugs de comparación. Pierde bugs de permutación.

Idempotencia: sort(sort(x)) == sort(x). Atrapa solo implementaciones genuinamente rotas que destruyen el ordenamiento. Casi inútil.

Estabilidad: si emparejas cada elemento con su index original, las claves iguales permanecen en el orden de entrada. Atrapa operadores de comparación que usan >= en lugar de >.

Subestructura: ordenar un prefijo y luego la lista completa debería coincidir en el orden del prefijo. Atrapa bugs de terminación temprana.

Una suite de pruebas con solo permutación e idempotencia pasaría un sort que siempre devuelve [1, 2, 3]. Una suite con permutación y orden atrapa ese bug. Añade estabilidad y también atrapas sorts inestables.

El punto no es coleccionar tantas relaciones como sea posible. El punto es cubrir modos de falla independientes. Dos relaciones que atrapan el mismo bug son peores que una relación que atrapa un bug diferente.

La compensación: las relaciones más fuertes son más difíciles de encontrar

Hay una razón por la que los equipos escriben relaciones débiles. Las relaciones fuertes requieren conocimiento de dominio. Necesitas entender la estructura matemática de tu problema lo suficientemente bien como para codificar un invariante no obvio.

Para la función de media, la invarianza por traslación es obvia para cualquiera con formación en estadística. Para una simulación de partículas, la relación equivalente podría requerir saber que la dinámica hamiltoniana preserva el volumen del espacio de fases. No todos los equipos tienen esa experiencia a mano.

El otro costo es la depuración. Cuando una relación fuerte falla, la violación te dice que alguna propiedad estructural se rompió, pero el bug podría estar en cualquier parte de la cadena de razonamiento que llevó a esa propiedad. Una relación débil como “la longitud de la salida igual a la longitud de la entrada” falla de exactamente una forma. Una relación fuerte como “la transformada de Fourier de una señal desplazada adquiere un término de fase lineal” falla de cien formas, y rastrear cuál es tu bug lleva más tiempo.

Esta es la tensión central. Las relaciones débiles son fáciles de escribir, fáciles de depurar y en su mayoría inútiles. Las relaciones fuertes son difíciles de escribir, difíciles de depurar y realmente encuentran bugs. No hay almuerzo gratis.

Cómo evaluar una metamorphic relation

Antes de añadir una relación a tu suite de pruebas, sométela a tres comprobaciones:

La prueba de bug deliberado. Introduce un bug realista en tu implementación. ¿Falló la relación? Si no, la relación no está cumpliendo su función. Prueba un error de signo, un error por uno, un argumento intercambiado, una condición de contorno faltante. Estos son los bugs que ocurren en producción. Tus relaciones deberían atraparlos.

La prueba de independencia. Mira tus relaciones existentes. ¿Atraparía alguna el mismo bug? Si es así, esta nueva relación es redundante. La redundancia no es seguridad. Es carga de mantenimiento sin beneficio marginal.

La prueba de falsificabilidad. ¿Puedes imaginarte una implementación rota plausible que satisfaga la relación? Si puedes bosquejar una en treinta segundos, la relación es demasiado débil. Una buena relación debería sentirse como una restricción ajustada, no como una sugerencia vaga.

Así es como se ve esto en código para la función de media:

import random

def mean(values):
    return sum(values) / len(values)

def test_translation_invariance():
    values = [random.uniform(-100, 100) for _ in range(20)]
    c = 5.5
    shifted = [x + c for x in values]
    assert mean(shifted) == mean(values) + c

def test_scaling():
    values = [random.uniform(-50, 50) for _ in range(20)]
    k = 3.0
    scaled = [x * k for x in values]
    assert mean(scaled) == mean(values) * k

Ahora introduce el bug de error por uno. Cambia len(values) a len(values) - 1. Ejecuta ambas pruebas. La invarianza por traslación falla inmediatamente. El escalado falla inmediatamente. El acotamiento probablemente pasaría.

Esa es la diferencia entre una relación que se gana su lugar en tu suite y una que solo ocupa líneas.

Empieza con clases de falla, no con propiedades

El error que la mayoría de los equipos cometen es hacer lluvia de ideas sobre propiedades primero. Preguntan: “¿Qué invariantes tiene esta función?” Eso produce relaciones débiles, porque los invariantes son fáciles de enunciar y difíciles de violar.

En su lugar, empieza con clases de falla. Pregunta: “¿Qué bugs escribiría un programador cansado en esta función?” Luego encuentra relaciones que atrapen esos bugs.

Para una función de distancia geométrica, los bugs probables son errores de signo, confusiones de unidades y discrepancias de dimensión. Una relación que comprueba que la distancia es no negativa atrapa errores de signo. Una relación que comprueba el escalado bajo transformaciones de coordenadas atrapa confusiones de unidades. Una relación que comprueba la desigualdad triangular atrapa disparates de dimensión.

Si no puedes nombrar el bug que atrapa una relación, no necesitas esa relación.

Las relaciones son un recurso escaso. Gástalas sabiamente.

El metamorphic testing no se trata de metrics de cobertura. Se trata de confianza. Una relación fuerte que atrapa bugs reales vale más que veinte relaciones débiles que pasan para código roto.

Audita tus pruebas metamórficas existentes. Introduce un bug. Observa qué falla. Elimina lo que no falla. Luego añade una relación para cada clase de falla de la que realmente te preocupes. Esa es una suite de pruebas que se gana el sueldo.