Las pruebas metamórficas han encontrado 147 bugs confirmados en GCC y LLVM, defectos en simuladores comerciales de ADAS usados por OEMs automotrices, y una falla fatal en el sistema de percepción de un vehículo autónomo ocho días antes de que matara a un peatón. La técnica suena académica, pero los bugs no lo son.

El problema es el problema del oráculo. Para muchos programas, puedes ejecutar entradas pero no puedes verificar de forma independiente que las salidas sean correctas. ¿Cuál es la ruta más corta exacta a través de una red vial con 10,000 nodes? ¿Esta optimización del compiler preserva la semántica? ¿La clasificación de este modelo de ML es realmente correcta? No lo sabes. Las unit tests tradicionales fracasan aquí porque no puedes escribir un assertEquals(expected, actual) cuando no tienes idea de qué debería ser expected.

Las pruebas metamórficas evitan esto al no verificar las salidas en absoluto. Verifican las relaciones entre las salidas.

¿Qué son las pruebas metamórficas?

Las pruebas metamórficas son una técnica en la que transformas una entrada en una entrada relacionada, ejecutas ambas a través de tu programa, y afirmas que las dos salidas obedecen una relación matemática o lógica conocida. La relación se denomina relación metamórfica.

Si tu programa calcula el promedio de una lista de números, no necesitas saber el promedio exacto de [4.2, 1.7, 9.3, 2.1] para probarlo. Solo necesitas saber que barajar la lista debería producir el mismo resultado, o que duplicar cada elemento debería duplicar el promedio. Estas son relaciones metamórficas.

La primera entrada es el caso de prueba fuente. La entrada transformada es el caso de prueba de tracing. El oráculo es la relación misma.

Aquí hay un ejemplo concreto en Python:

import random

def compute_average(numbers):
    """Returns the arithmetic mean of a list of numbers."""
    if not numbers:
        raise ValueError("empty list")
    return sum(numbers) / len(numbers)

def test_average_permutation_invariant():
    """MR-1: Shuffling the input should not change the average."""
    source = [4.2, 1.7, 9.3, 2.1, 5.6]
    follow_up = source.copy()
    random.shuffle(follow_up)

    source_out = compute_average(source)
    follow_up_out = compute_average(follow_up)

    assert source_out == follow_up_out, (
        f"Permutation MR failed: {source_out} != {follow_up_out}"
    )

def test_average_scaling():
    """MR-2: Doubling every element should double the average."""
    source = [3.0, 6.0, 9.0]
    follow_up = [x * 2 for x in source]

    source_out = compute_average(source)
    follow_up_out = compute_average(follow_up)

    assert follow_up_out == source_out * 2, (
        f"Scaling MR failed: {follow_up_out} != {source_out * 2}"
    )

def test_average_inclusion():
    """MR-3: Appending the average itself should not decrease the average."""
    source = [10.0, 20.0, 30.0]
    source_out = compute_average(source)
    follow_up = source + [source_out]
    follow_up_out = compute_average(follow_up)

    assert follow_up_out == source_out, (
        f"Inclusion MR failed: {follow_up_out} != {source_out}"
    )

if __name__ == "__main__":
    test_average_permutation_invariant()
    test_average_scaling()
    test_average_inclusion()
    print("All metamorphic relations passed.")

Si alguna de estas relaciones falla, has encontrado un bug sin haber calculado nunca el promedio esperado a mano. Esta es la idea central.

Bugs reales encontrados en sistemas de producción

La técnica no es teórica. Aquí hay casos documentados en los que las pruebas metamórficas encontraron bugs reales en software de producción.

147 bugs en GCC y LLVM

Los investigadores aplicaron pruebas metamórficas a los pipelines de optimización de compilers de C y encontraron 147 bugs confirmados en GCC y LLVM. Estos no eran programas de juguete. Eran bugs reales de compilación incorrecta donde un programa de C correcto, al ejecutarse a través de un compiler optimizador, producía código máquina incorrecto. Algunos de estos bugs habían existido durante años. Las relaciones metamórficas eran simples: si haces inline de una función a mano, la salida optimizada debería comportarse igual que la original. Si permutas sentencias independientes, el resultado no debería cambiar. Los desarrolladores del compiler confirmaron y corrigieron estos bugs.

Compilers de shaders Vulkan en Google

El equipo de GraphicsFuzz de Google puso pruebas metamórficas aleatorizadas en producción para el Khronos Vulkan Conformance Test Suite. Generaron fragment shaders aleatorios, aplicaron transformaciones que preservan la semántica (como envolver expresiones en funciones identidad o agregar código muerto), y compararon imágenes renderizadas entre diferentes compilers y GPUs. Cuando dos shaders supuestamente equivalentes producían píxeles diferentes, habían encontrado un bug en el compiler. El equipo construyó un pipeline completo llamado gfauto para reducir, desduplicar y reportar estos casos. Encontraron bugs en el ecosistema de herramientas que transforman, optimizan y validan shaders Vulkan, incluyendo drivers de producción enviados a usuarios finales.

Simuladores de ADAS usados por OEMs automotrices

Un equipo probó tres plataformas populares de simulación de ADAS, Simulink, CarMaker y 51Sim-One Cloud, centrándose en sus sistemas de asistencia para mantenimiento de carril. Los casos de prueba ordinarios pasaron en las tres plataformas. Ningún problema en absoluto. Luego el equipo aplicó relaciones metamórficas geométricas: reflejar la escena vial horizontalmente, rotar la posición del vehículo, aplicar transformaciones afines a las marcas de carril. Las salidas deberían transformarse de manera predecible. No lo hicieron. Se revelaron bugs en las tres plataformas. MathWorks e IPG Automotive confirmaron posteriormente los problemas. Estas son las mismas plataformas usadas para validar software antes de que vaya a los vehículos.

El defecto del self-driving car en la misma clase

En uno de los casos más impactantes, los investigadores aplicaron pruebas metamórficas a un sistema de detección de objetos para vehículos autónomos y encontraron un bug en el pipeline de percepción. El sistema falló al clasificar correctamente peatones bajo entradas transformadas específicas. Lo reportaron. El bug que encontraron era de la misma clase de defecto que ha sido implicada en colisiones fatales de self-driving car con peatones.

La compensación: las relaciones son específicas del dominio

Las pruebas metamórficas son poderosas, pero no son gratuitas. La parte difícil es identificar buenas relaciones metamórficas. Una mala relación te da falsa confianza. Una relación que es demasiado débil no atrapará bugs. Una relación que es demasiado fuerte fallará en comportamiento correcto debido a ruido de punto flotante o no determinismo.

Diseñar relaciones requiere conocimiento del dominio. Para un algoritmo de ruta más corta, buenas relaciones incluyen: el costo de la ruta A->B debería ser igual a B->A en un grafo no dirigido; agregar una constante a cada peso de arista debería agregar esa constante multiplicada por el número de aristas al costo total de la ruta. Para un algoritmo de ordenamiento: invertir una lista ordenada y ordenarla debería dar el inverso de la lista ordenada original; cada elemento en la salida debería aparecer en la entrada con la misma frecuencia.

No puedes reutilizar las mismas relaciones entre sistemas no relacionados. Ese es el costo.

La aritmética de punto flotante es otra trampa. Muchas relaciones asumen igualdad exacta, pero 0.1 + 0.2 != 0.3 en IEEE 754. Necesitas comparaciones basadas en tolerancia, y elegir la tolerancia correcta es su propio problema. Demasiado estricta y obtienes falsos positivos. Demasiado laxa y te pierdes bugs reales.

Cómo agregar pruebas metamórficas a tu codebase

No necesitas un framework. Necesitas disciplina.

Comienza con las funciones en tu codebase que no tienen oráculo. La inferencia de ML, algoritmos de optimización, computaciones geométricas, agregaciones estadísticas y código de simulación son todos candidatos. Para cada uno, pregúntate: ¿qué debe ser cierto sobre la salida si cambio la entrada de una manera específica y predecible?

Escribe una relación metamórfica por función de prueba. Nómbrala claramente. Ejecútala en CI junto con tus unit tests. Cuando una relación falla, trátala exactamente como cualquier otra falla de prueba.

Aquí hay un ejemplo ligeramente más realista probando una función de búsqueda de rutas:

import math

def shortest_path_cost(graph, start, end):
    """Returns the cost of the shortest path. Assume implemented."""
    pass

def test_shortest_path_undirected_symmetry():
    """MR: In an undirected graph, path cost A->B equals B->A."""
    graph = {
        'A': [('B', 3.0), ('C', 1.0)],
        'B': [('A', 3.0), ('C', 1.0)],
        'C': [('A', 1.0), ('B', 1.0)],
    }
    ab = shortest_path_cost(graph, 'A', 'B')
    ba = shortest_path_cost(graph, 'B', 'A')
    assert math.isclose(ab, ba, rel_tol=1e-9), f"Symmetry failed: {ab} != {ba}"

def test_shortest_path_subpath():
    """MR: The shortest path cost cannot exceed any specific path's cost."""
    graph = {
        'A': [('B', 2.0), ('C', 10.0)],
        'B': [('C', 2.0)],
        'C': [],
    }
    cost = shortest_path_cost(graph, 'A', 'C')
    assert cost <= 10.0, f"Subpath MR failed: {cost} > 10.0"
    assert math.isclose(cost, 4.0, rel_tol=1e-9), f"Expected 4.0, got {cost}"

No estás probando el algoritmo en sí. Estás probando tu implementación de él.

Preguntas frecuentes

¿Las pruebas metamórficas reemplazan las unit tests?

No. Las complementan. Usa unit tests cuando conoces la salida esperada. Usa pruebas metamórficas cuando no la conoces.

¿Puedo usar esto para modelos de ML?

Sí, y es una de las áreas de investigación más activas. Relaciones como “rotar una imagen de un gato debería seguir clasificándose como un gato” son relaciones metamórficas. Los investigadores han encontrado problemas de confiabilidad de modelos y brechas de equidad usando este enfoque.

¿Cómo sé que mi relación metamórfica es correcta?

No la demuestras. Argumentas a partir de la especificación o de las propiedades matemáticas del dominio. Si tu relación misma tiene bugs, obtendrás falsos positivos. Comienza con propiedades obvias y agrega más a medida que ganas confianza.

¿Qué pasa con las pruebas inestables?

Los sistemas no deterministas (algoritmos probabilísticos, código concurrente, sistemas con timeouts) hacen las pruebas metamórficas más difíciles. Puedes necesitar ejecutar múltiples intentos o usar relaciones estadísticas en lugar de igualdad exacta.

Comienza con una relación

No necesitas un doctorado para usar esto. Elige una función en tu sistema donde actualmente omites las pruebas porque verificar la salida es demasiado difícil. Escribe una relación metamórfica. Ejecútala. Si quieres profundizar, las herramientas gfauto del equipo de GraphicsFuzz son de código abierto, y la encuesta de ACM por Segura et al. cataloga relaciones a través de docenas de dominios.

La técnica encontró 147 bugs en compilers, confirmó defectos en plataformas de simulación automotriz, y expuso una clase de fallas de percepción en vehículos autónomos antes de que llegaran a la carretera. Los bugs son reales. La única pregunta es si los estás buscando.