Despliegas un modelo de aprendizaje automático que tag tickets de soporte. Tu suite de pruebas está en verde. Cada prueba pasó.
Ninguna de esas pruebas comprueba realmente si las tags son correctas. No sabes cuáles son las tags correctas. Nadie las sabe. La salida “correcta” es prácticamente incognoscible para entradas del mundo real, así que recurres a comprobar que la función no se cuelgue, o que la forma de la salida coincida con lo esperado. Eso no es probar. Eso es esperar.
Este es el oracle problem, y aparece en todas partes: compilers, simulaciones, algoritmos de optimización, detectores de fraude y cualquier sistema donde la verdad fundamental es costosa, subjetiva o imposible de calcular. Cuando no puedes definir la salida esperada, las unit tests tradicionales se desmoronan.
El metamorphic testing ofrece una salida. En lugar de preguntar “¿es correcta esta salida?”, preguntas “¿las entradas relacionadas producen salidas que satisfacen una relación conocida?” Si no lo hacen, tu código está roto. Si lo hacen, has ganado confianza real sin conocer jamás la respuesta correcta.
¿Qué es el metamorphic testing?
El metamorphic testing fue propuesto a finales de los años noventa por Tsong Yueh Chen y colegas como respuesta a una realidad frustrante: muchos programas son genuinamente útiles pero prácticamente intratables con oráculos tradicionales.
La idea central es simple. Identificas una metamorphic relation: una propiedad que debería mantenerse entre las entradas y salidas de múltiples ejecuciones de tu programa. Ejecutas el programa con una entrada fuente, transformas esa entrada según la relación, lo ejecutas de nuevo, y compruebas si las salidas se relacionan entre sí de la forma que predice la propiedad.
No se requiere salida esperada. No etiquetadores humanos. No dataset dorado.
Considera una función que encuentra la ruta más corta en un grafo ponderado:
from typing import List, Tuple, Optional
def shortest_path(
edges: List[Tuple[int, int, float]],
start: int,
end: int
) -> Optional[float]:
"""Dijkstra's algorithm. Returns path length or None if unreachable."""
import heapq
graph = {}
for u, v, w in edges:
graph.setdefault(u, []).append((v, w))
dist = {start: 0.0}
heap = [(0.0, start)]
while heap:
d, u = heapq.heappop(heap)
if u == end:
return d
if d > dist.get(u, float('inf')):
continue
for v, w in graph.get(u, []):
nd = d + w
if nd < dist.get(v, float('inf')):
dist[v] = nd
heapq.heappush(heap, (nd, v))
return None
Para un grafo complejo, calcular la ruta más corta esperada a mano es tedioso. Pero conocemos varias metamorphic relations que deben mantenerse:
-
Monotonicity con respecto a los pesos de las aristas. Si aumentas el peso de cualquier arista individual, la ruta más corta no debería acortarse. Puede mantenerse igual (si esa arista no estaba en la ruta óptima) o alargarse.
-
Homogeneidad bajo escalado. Si multiplicas cada peso de arista por una constante positiva, la longitud de la ruta más corta debería escalar por la misma constante.
-
Simetría de ruta en grafos no dirigidos. Si el grafo es no dirigido, intercambiar inicio y fin debería dar la misma longitud de ruta.
Estas no son heurísticas. Son propiedades matemáticas. Si alguna falla, la implementación está equivocada, sin discusión.
Cómo escribir una prueba metamórfica
Así es como se ve la relación de monotonicity en la práctica:
import random
def test_shortest_path_monotonicity():
# Generate a random connected graph
nodes = list(range(10))
edges = []
for i in range(len(nodes) - 1):
edges.append((i, i + 1, random.uniform(1.0, 10.0)))
# Add some random cross edges
for _ in range(10):
u, v = random.sample(nodes, 2)
edges.append((u, v, random.uniform(1.0, 10.0)))
start, end = 0, 9
original = shortest_path(edges, start, end)
assert original is not None
# Increase the weight of one arbitrary edge
idx = random.randrange(len(edges))
u, v, w = edges[idx]
modified_edges = list(edges)
modified_edges[idx] = (u, v, w + 5.0)
modified = shortest_path(modified_edges, start, end)
assert modified is not None
assert modified >= original
Esta prueba nunca calcula la ruta más corta esperada. No necesita hacerlo. Comprueba que se mantiene una propiedad estructural, lo cual es suficiente para detectar una sorprendente variedad de bugs: errores de signo, errores por uno en la acumulación de pesos, orden incorrecto de la cola de prioridad, y más.
La relación de scaling es aún más simple de probar:
def test_shortest_path_scaling():
nodes = list(range(8))
edges = []
for i in range(len(nodes) - 1):
edges.append((i, i + 1, random.uniform(2.0, 5.0)))
start, end = 0, 7
original = shortest_path(edges, start, end)
factor = 3.5
scaled_edges = [(u, v, w * factor) for u, v, w in edges]
scaled = shortest_path(scaled_edges, start, end)
assert abs(scaled - original * factor) < 1e-9
Observa la tolerancia de punto flotante. Las pruebas metamórficas no son inmunes a los problemas de precisión numérica, así que escribe tus afirmaciones con el mismo cuidado que usarías en cualquier otra prueba numérica.
Dónde este enfoque realmente ayuda
El metamorphic testing brilla en dominios donde los oráculos tradicionales son débiles o inexistentes.
Aprendizaje automático. No conoces el score exacto de sentimiento para una reseña de película, pero sabes que añadir la palabra “terrible” no debería aumentar el sentimiento positivo. No conoces el bounding box exacto para un detector de objetos, pero sabes que voltear la imagen horizontalmente debería voltear las coordenadas del bounding box.
Compilers. Verificar que un binario optimizado produzca exactamente la misma salida que uno no optimizado para cada programa posible es imposible. Pero puedes comprobar que compilar un programa, y luego compilarlo de nuevo con una transformación no-op (como renombrar una variable), produce binarios semánticamente equivalentes.
Computación científica. No conoces la trayectoria exacta de una partícula en una simulación compleja, pero sabes que invertir el tiempo debería invertir la trayectoria. No conoces la energía exacta del estado fundamental de una molécula, pero sabes que debería disminuir (o mantenerse constante) a medida que aumentas el tamaño del basis set.
En cada caso, la intuición es la misma: la corrección no siempre se trata de coincidir con un valor esperado único. A veces se trata de preservar la estructura a través de transformaciones.
Las compensaciones y limitaciones
El metamorphic testing no es gratis, y no es un reemplazo para todo otro tipo de prueba.
Las relaciones pueden ser incompletas. Un programa puede pasar cada metamorphic relation que definas y aún estar equivocado. Si tu conjunto de relaciones no cubre una clase de bug particular, ese bug se cuela. Este es el coverage problem, y es real.
Las relaciones pueden estar equivocadas. Si afirmas erróneamente que una propiedad se mantiene cuando no es así, tu prueba se convierte en una fábrica de falsos positivos. Una vez vi a un equipo afirmar que el clustering k-means debería ser invariante al escalado de características. No lo es. Los centroides escalan con los datos. La prueba pasó durante meses porque el factor de escalado resultó ser 1.0 en los datos de prueba. Cuando llegaron los datos reales, el modelo se degradó silenciosamente mientras las pruebas seguían en verde.
Depurar fallas es más difícil. Cuando una unit test tradicional falla, sabes exactamente cuál era la salida esperada. Cuando una prueba metamórfica falla, sabes que se violó una relación, pero aún no conoces la salida correcta. Tienes que razonar hacia atrás desde la violación de la propiedad hasta el bug subyacente, lo cual puede ser más trabajo.
La generación de datos de prueba importa. Grafos aleatorios, oraciones aleatorias e imágenes aleatorias no son representativas de entradas reales. Una prueba metamórfica con datos sintéticos puede pasar mientras el sistema en producción falla en casos límite que tu generator nunca produjo. Usa bibliotecas de property-based testing como Hypothesis para ayudar, pero mantén el escepticismo sobre tus propios generators.
Cómo empezar a usarlo hoy
No necesitas un framework nuevo. Necesitas tres cosas:
-
Elige una función con un oráculo débil. Un método de inferencia de modelo, una computación geométrica, un paso de simulación. Algo donde actualmente pruebas “no se cuelga” y desearías poder hacer más.
-
Lluvia de ideas sobre tres relaciones. Pregúntate: ¿qué transformaciones deberían dejar la salida sin cambios? ¿Qué transformaciones deberían cambiar la salida de forma predecible? ¿Qué pares de entradas deberían producir salidas relacionadas? Escríbelas, incluso si parecen obvias.
-
Implementa una relación como prueba. Ejecútala con entradas aleatorizadas. Si falla, encontraste un bug o una relación equivocada. Ambos son valiosos.
Para el ejemplo de k-means que mencioné antes, un conjunto de relaciones correcto se ve así:
import numpy as np
from sklearn.cluster import KMeans
def test_kmeans_translation_invariance():
X = np.random.rand(100, 3)
shift = np.array([10.0, -5.0, 2.0])
km1 = KMeans(n_clusters=3, random_state=42, n_init=10).fit(X)
km2 = KMeans(n_clusters=3, random_state=42, n_init=10).fit(X + shift)
# Centroids should differ by exactly the shift vector
np.testing.assert_allclose(km1.cluster_centers_ + shift, km2.cluster_centers_)
Esta prueba fallará si la lógica de clustering maneja mal el sistema de coordenadas, y lo hace sin afirmar jamás cuáles son los centroides correctos.
Cuando no puedes conocer la respuesta, prueba la estructura
El oracle problem no es un caso límite del testing. Es el estado por defecto para una enorme clase de software útil. El metamorphic testing no lo resuelve por completo, pero te mueve de “no puedo probar esto” a “puedo probar las propiedades que importan”.
Empieza con una relación, una función y un bug real que atrape. Eso es suficiente para justificar el enfoque. Todo lo demás es simplemente añadir más relaciones.