El Problema del Oráculo
El testing tradicional de software depende de oráculos de prueba—mecanismos para determinar si un test pasó o falló. Para una calculadora, el oráculo es directo: add(2, 3)
debería retornar 5
. ¿Pero qué sucede cuando no conoces la salida esperada?
Considera probar un modelo de machine learning que recomienda películas. ¿Cuál es la recomendación “correcta” para un usuario dado? ¿O un sistema de pronóstico del tiempo—cuál es la temperatura precisa pronosticada para el próximo martes? ¿O una optimización de compilador—cómo puedes verificar que el código optimizado es funcionalmente equivalente al original?
Estos escenarios sufren del problema del oráculo: la ausencia de un mecanismo confiable para verificar corrección. El metamorphic testing ofrece una solución elegante al enfocarse en relaciones entre entradas y salidas en lugar de corrección absoluta.
¿Qué es Metamorphic Testing?
El metamorphic testing valida software verificando relaciones metamórficas (MRs)—propiedades que deberían mantenerse a través de diferentes ejecuciones del programa. En lugar de preguntar “¿Es correcta esta salida?”, el metamorphic testing pregunta “¿Es la relación entre estas salidas consistente con el comportamiento esperado?”
Concepto Central
Una relación metamórfica define cómo los cambios en la entrada deberían afectar la salida. Por ejemplo:
MR de Motor de Búsqueda: Si la consulta Q retorna resultados R, entonces la consulta “Q Q” (consulta duplicada) debería retornar los mismos resultados R.
MR de Función Trigonométrica: Para la función sin(x)
, sabemos que sin(x + 2π) = sin(x)
para cualquier x. Podemos probar esto sin conocer el valor exacto de sin(x)
.
El insight brillante: incluso sin conocer qué es sin(0.7)
precisamente, podemos verificar que sin(0.7) == sin(0.7 + 2π)
. Si esta relación es violada, hemos encontrado un bug—sin necesitar nunca un oráculo de prueba.
Relaciones Metamórficas: Tipos y Ejemplos
Relaciones de Identidad
La salida debería permanecer sin cambios bajo ciertas transformaciones de entrada.
Ejemplo: Procesamiento de Imagen
# Relación Metamórfica: Doble rotación 180° = 360° = original
def test_image_rotation_identity():
original_image = load_image("test.jpg")
rotated_180 = rotate(original_image, 180)
rotated_360 = rotate(rotated_180, 180)
assert images_equal(original_image, rotated_360), \
"MR violada: rotación 360° debería retornar imagen original"
Ejemplo: Encriptación
# MR: Encriptar luego desencriptar debería retornar original
def test_encryption_identity():
original_data = "información sensible"
key = generate_key()
encrypted = encrypt(original_data, key)
decrypted = decrypt(encrypted, key)
assert original_data == decrypted, \
"MR violada: decrypt(encrypt(data)) debería igualar data"
Invariancia de Permutación
La salida no debería verse afectada por la permutación del orden de entrada.
Ejemplo: Operaciones de Conjunto
# MR: La unión de conjuntos es conmutativa
def test_set_union_commutativity():
set_a = {1, 2, 3}
set_b = {3, 4, 5}
result_ab = set_a.union(set_b)
result_ba = set_b.union(set_a)
assert result_ab == result_ba, \
"MR violada: A∪B debería igualar B∪A"
Ejemplo: Sistema de Recomendación
# MR: El orden de preferencias del usuario no debería afectar distribución de género
def test_recommendation_permutation_invariance():
user_preferences = ["sci-fi", "thriller", "drama"]
recommendations_1 = get_recommendations(user_preferences)
shuffled_prefs = ["drama", "sci-fi", "thriller"]
recommendations_2 = get_recommendations(shuffled_prefs)
# La distribución de género debería ser similar
assert genre_similarity(recommendations_1, recommendations_2) > 0.8, \
"MR violada: orden de preferencias afecta significativamente recomendaciones"
Relaciones de Monotonicidad
La salida cambia monotónicamente con cambios de entrada.
Ejemplo: Relevancia de Búsqueda
# MR: Agregar términos relevantes debería aumentar scores de relevancia
def test_search_relevance_monotonicity():
query_basic = "python programming"
results_basic = search(query_basic)
query_enhanced = "python programming tutorial"
results_enhanced = search(query_enhanced)
# El resultado principal para consulta mejorada debería tener mayor relevancia
# a tutoriales de programación que consulta básica
assert relevance_score(results_enhanced[0], "tutorial") >= \
relevance_score(results_basic[0], "tutorial"), \
"MR violada: Agregar término relevante no aumentó relevancia"
Relaciones de Equivalencia
Diferentes entradas deberían producir salidas equivalentes.
Ejemplo: Optimización de Compilador
# MR: Código optimizado debería producir mismos resultados que no optimizado
def test_compiler_optimization_equivalence():
source_code = """
int sum = 0;
for (int i = 0; i < 1000; i++) {
sum += i;
}
return sum;
"""
# Compilar sin optimización
binary_o0 = compile(source_code, optimization_level=0)
result_o0 = execute(binary_o0)
# Compilar con optimización agresiva
binary_o3 = compile(source_code, optimization_level=3)
result_o3 = execute(binary_o3)
assert result_o0 == result_o3, \
"MR violada: Optimización cambió semántica del programa"
Relaciones Aditivas
La salida para entradas combinadas se relaciona predeciblemente con salidas individuales.
Ejemplo: Calculadora de Impuestos
# MR: Impuesto sobre artículos separados debería igualar impuesto sobre total combinado
def test_tax_calculation_additivity():
item1_price = 100.0
item2_price = 50.0
tax_separate = calculate_tax(item1_price) + calculate_tax(item2_price)
tax_combined = calculate_tax(item1_price + item2_price)
assert abs(tax_separate - tax_combined) < 0.01, \
"MR violada: Cálculo de impuesto no es aditivo"
Metamorphic Testing para Machine Learning
Los modelos ML son el ejemplo paradigmático del problema del oráculo. ¿Cómo verificas que un clasificador de imágenes identifica correctamente un gato cuando “gatidad” es subjetivo?
Relaciones Metamórficas de Clasificación de Imagen
Invariancia de Brillo
def test_classification_brightness_invariance():
original_image = load_image("cat.jpg")
prediction_original = model.predict(original_image)
# Ajuste leve de brillo no debería cambiar clasificación
brightened_image = adjust_brightness(original_image, factor=1.1)
prediction_brightened = model.predict(brightened_image)
assert prediction_original == prediction_brightened, \
"MR violada: Cambio menor de brillo alteró clasificación"
Invariancia de Rotación (para dominios apropiados)
def test_classification_rotation_invariance():
image = load_image("stop_sign.jpg")
prediction_0 = model.predict(image)
# Señal de alto debería ser reconocida independientemente de rotación leve
rotated_image = rotate(image, angle=5)
prediction_rotated = model.predict(rotated_image)
assert prediction_0 == prediction_rotated, \
"MR violada: Rotación pequeña cambió clasificación"
Monotonicidad de Confianza
def test_confidence_monotonicity():
noisy_image = add_noise(original_image, noise_level=0.3)
confidence_noisy = model.predict_proba(noisy_image)
clean_image = original_image
confidence_clean = model.predict_proba(clean_image)
assert confidence_clean >= confidence_noisy, \
"MR violada: Imagen ruidosa tiene mayor confianza que limpia"
MRs de Procesamiento de Lenguaje Natural
Análisis de Sentimiento
def test_sentiment_negation():
positive_text = "Este producto es excelente"
sentiment_positive = analyze_sentiment(positive_text)
negative_text = "Este producto no es excelente"
sentiment_negative = analyze_sentiment(negative_text)
assert sentiment_positive > 0 and sentiment_negative < 0, \
"MR violada: Negación no invirtió sentimiento"
Consistencia de Traducción
def test_translation_round_trip():
original_text = "El gato estaba en la alfombra"
# Traducir Español -> Inglés -> Español
english = translate(original_text, source="es", target="en")
back_to_spanish = translate(english, source="en", target="es")
similarity = semantic_similarity(original_text, back_to_spanish)
assert similarity > 0.8, \
"MR violada: Traducción ida y vuelta perdió significado significativo"
Aplicaciones de Computación Científica
Las simulaciones científicas y métodos numéricos son candidatos principales para metamorphic testing.
MR de Simulación de Física
def test_physics_conservation_of_energy():
"""Probar que simulación de partículas conserva energía"""
initial_state = {
'positions': [(0, 0), (1, 0)],
'velocities': [(1, 0), (-1, 0)],
'masses': [1, 1]
}
# Simular por tiempo T
final_state = simulate_particles(initial_state, time=10.0)
# MR: Energía total debería ser conservada (dentro de error numérico)
initial_energy = calculate_total_energy(initial_state)
final_energy = calculate_total_energy(final_state)
assert abs(initial_energy - final_energy) < 0.001, \
"MR violada: Energía no conservada en simulación"
MR de Integración Numérica
def test_integration_subdivision():
"""Probar que subdividir intervalo de integración da mismo resultado"""
def function(x):
return x**2
# Integrar sobre [0, 10]
result_full = numerical_integrate(function, a=0, b=10)
# Integrar sobre [0, 5] y [5, 10] separadamente
result_part1 = numerical_integrate(function, a=0, b=5)
result_part2 = numerical_integrate(function, a=5, b=10)
result_subdivided = result_part1 + result_part2
assert abs(result_full - result_subdivided) < 0.0001, \
"MR violada: Subdivisión cambió resultado de integración"
Validación de Compiladores e Intérpretes
El metamorphic testing es invaluable para validar compiladores y transformaciones de programa.
Equivalencia de Transformación de Código
def test_dead_code_elimination():
"""Verificar que eliminación de código muerto preserva semántica"""
original_code = """
x = 10
y = 20 # Muerto: nunca usado
return x
"""
optimized_code = optimize(original_code, passes=["dead_code_elimination"])
# MR: Ambas versiones deberían producir misma salida para todas las entradas
test_inputs = [(), (1,), (1, 2)]
for inputs in test_inputs:
result_original = execute(original_code, inputs)
result_optimized = execute(optimized_code, inputs)
assert result_original == result_optimized, \
f"MR violada: Optimización cambió salida para {inputs}"
Validación Cruzada de Compiladores
def test_cross_compiler_consistency():
"""Diferentes compiladores deberían producir binarios equivalentes"""
source_code = read_file("program.c")
binary_gcc = compile_with_gcc(source_code)
binary_clang = compile_with_clang(source_code)
# MR: Binarios deberían producir resultados idénticos
test_cases = generate_test_cases(100)
for test_input in test_cases:
result_gcc = execute(binary_gcc, test_input)
result_clang = execute(binary_clang, test_input)
assert result_gcc == result_clang, \
f"MR violada: Compiladores difieren en entrada {test_input}"
Caso de Estudio: Planificación de Ruta de Vehículo Autónomo
Una compañía de vehículos autónomos usó metamorphic testing para validar su algoritmo de planificación de ruta sin conocer la ruta “correcta”.
Relaciones Metamórficas Probadas:
- MR de Adición de Obstáculo: Agregar un obstáculo nunca debería disminuir longitud de ruta
- MR de Simetría: Escenarios reflejados deberían producir rutas reflejadas
- MR Incremental: Planificar A→B→C debería coincidir con planificar A→C cuando B está en la ruta óptima
- MR de Seguridad: Cualquier ruta válida debe mantener distancia mínima de obstáculos
Resultados:
- Descubrió 23 bugs que el testing tradicional omitió
- Encontró caso límite donde algoritmo violaba margen de seguridad durante giros cerrados
- No se necesitó oráculo—violaciones de MRs indicaron defectos
Ejemplo de Implementación:
def test_obstacle_addition_monotonicity():
"""Agregar obstáculo no debería acortar ruta"""
start = (0, 0)
goal = (100, 100)
obstacles_few = [Circle(50, 50, 5)]
path_few_obstacles = plan_path(start, goal, obstacles_few)
obstacles_many = obstacles_few + [Circle(60, 60, 5)]
path_many_obstacles = plan_path(start, goal, obstacles_many)
assert len(path_many_obstacles) >= len(path_few_obstacles), \
"MR violada: Agregar obstáculo acortó ruta"
Framework de Metamorphic Testing
Un framework reutilizable para metamorphic testing:
from abc import ABC, abstractmethod
from typing import Callable, Any, List
class MetamorphicRelation(ABC):
"""Clase base para relaciones metamórficas"""
@abstractmethod
def generate_follow_up_input(self, original_input: Any) -> Any:
"""Generar entrada de seguimiento desde original"""
pass
@abstractmethod
def check_relation(self, original_output: Any, followup_output: Any) -> bool:
"""Verificar que la relación metamórfica se mantiene"""
pass
class PermutationInvariance(MetamorphicRelation):
"""MR: Salida invariante bajo permutación de entrada"""
def generate_follow_up_input(self, original_input: List) -> List:
import random
shuffled = original_input.copy()
random.shuffle(shuffled)
return shuffled
def check_relation(self, original_output: Any, followup_output: Any) -> bool:
return set(original_output) == set(followup_output)
class MetamorphicTester:
"""Framework para ejecutar tests metamórficos"""
def __init__(self, program: Callable):
self.program = program
self.relations: List[MetamorphicRelation] = []
def add_relation(self, relation: MetamorphicRelation):
self.relations.append(relation)
def test(self, input_generator: Callable, num_tests: int = 100):
violations = []
for i in range(num_tests):
original_input = input_generator()
original_output = self.program(original_input)
for relation in self.relations:
followup_input = relation.generate_follow_up_input(original_input)
followup_output = self.program(followup_input)
if not relation.check_relation(original_output, followup_output):
violations.append({
'test_id': i,
'relation': type(relation).__name__,
'original_input': original_input,
'followup_input': followup_input,
'original_output': original_output,
'followup_output': followup_output
})
return violations
Mejores Prácticas para Metamorphic Testing
Comienza con Conocimiento del Dominio: Las MRs a menudo surgen de propiedades matemáticas, leyes físicas o restricciones de lógica de negocio
Combina Múltiples MRs: Una sola MR puede omitir bugs que una combinación revela
Prioriza MRs de Alto Valor: Enfócate en relaciones que codifican propiedades críticas del sistema
Automatiza Verificación de MR: Integra tests metamórficos en pipelines CI/CD
Documenta MRs Claramente: Cada MR debería declarar su suposición y qué valida
Usa MRs para Complementar, No Reemplazar: Combina metamorphic testing con enfoques de testing tradicionales
Conclusión: Testing Más Allá del Oráculo
El metamorphic testing representa un cambio de paradigma en validación de software. Al enfocarse en relaciones en lugar de corrección absoluta, desbloquea capacidades de testing para dominios previamente considerados no probables:
- Modelos de machine learning
- Simulaciones científicas
- Compiladores y optimizadores
- Sistemas no determinísticos
- Transformaciones complejas de datos
La técnica no elimina la necesidad de testing tradicional—lo complementa. Cuando tienes un oráculo confiable, úsalo. Cuando no, las relaciones metamórficas proporcionan una alternativa poderosa que puede revelar defectos que el testing convencional omitiría.
La próxima vez que enfrentes un sistema no testeable, no preguntes “¿Cuál es la salida correcta?” sino “¿Qué relaciones deberían mantenerse entre salidas?” Ese cambio de perspectiva abre posibilidades de testing completamente nuevas.