Qué te ofrece realmente el testing diferencial

Puedes confiar en el testing diferencial sin una prueba formal, pero solo si entiendes exactamente dónde se desmorona.

La debilidad se llama common-mode failure. Cuando cada implementación de una especificación hace la misma suposición errónea, todas coinciden y tu test harness lo marca como aprobado. La N-version programming no te protege contra una mala especificación.

El testing diferencial funciona ejecutando múltiples implementaciones independientes de la misma especificación contra la misma entrada. Si sus salidas discrepan, al menos una tiene bugs. Si coinciden, tentativamente lo das por bueno.

Esto es poderoso porque elimina la necesidad de un test oracle. Un oracle es una fuente de verdad que conoce la respuesta correcta para cada entrada. Para sistemas complejos, los oracles suelen ser más difíciles de construir que el propio sistema. Un motor de impuestos, una simulación de física o un decoder de protocol pueden probarse por consistencia mucho antes de que puedas demostrar qué debería ser cada salida.

Pero la tentatividad importa. El acuerdo solo prueba consistencia. No prueba corrección.

Por qué el acuerdo no es corrección

El modo de fallo que todo el mundo aprende en teoría pero olvida en práctica es el common-mode fault. Cuando el error se origina en la propia especificación, o en una suposición compartida que todos los equipos de implementación hacen de forma independiente, cada versión produce la misma respuesta errónea.

La especificación no tiene que estar equivocada de forma obvia. Solo tiene que ser ambigua en un edge case que los cerebros humanos resuelven de la misma manera.

Imagina una especificación para una función que calcula el área de un polígono simple a partir de una lista de vértices. La especificación proporciona la shoelace formula. Nunca menciona el orden de los vértices.

Tres equipos la implementan. Los tres asumen orden counter-clockwise porque así es como está dibujado el diagrama de ejemplo. Las entradas clockwise producen área negativa en la fórmula en bruto. Los tres envuelven silenciosamente el resultado con abs() porque el área debe ser positiva. Coinciden en cada caso de prueba.

Pero la especificación nunca dijo que clockwise fuera inválido. Las implementaciones son consistentes y erróneas por omisión. El testing diferencial les da luz verde a todas.

Tres implementaciones, una especificación ambigua

Aquí hay un ejemplo concreto que puedes ejecutar. La especificación dice: “Parsea una cadena de duración y devuelve el número total de segundos. Una duración consiste en uno o más componentes. Cada componente es un entero positivo seguido de una letra de unidad: h para horas, m para minutos, s para segundos.”

Tres equipos reciben esta especificación y escriben sus propios parsers.

import re

def parse_duration_a(s):
    """Team A: regex approach."""
    if not isinstance(s, str):
        raise TypeError("input must be a string")
    m = re.fullmatch(r"(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?", s)
    if not m or not any(m.groups()):
        raise ValueError(f"invalid duration: {s}")
    h, mn, sec = (int(x or 0) for x in m.groups())
    return h * 3600 + mn * 60 + sec

def parse_duration_b(s):
    """Team B: left-to-right scanner."""
    if not isinstance(s, str):
        raise TypeError("input must be a string")
    total = 0
    i = 0
    while i < len(s):
        j = i
        while j < len(s) and s[j].isdigit():
            j += 1
        if j == i:
            raise ValueError(f"expected number at position {i}")
        num = int(s[i:j])
        if j >= len(s):
            raise ValueError(f"missing unit after {num}")
        unit = s[j]
        if unit == 'h':
            total += num * 3600
        elif unit == 'm':
            total += num * 60
        elif unit == 's':
            total += num
        else:
            raise ValueError(f"invalid unit: {unit}")
        i = j + 1
    return total

def parse_duration_c(s):
    """Team C: state machine with duplicate detection."""
    if not isinstance(s, str):
        raise TypeError("input must be a string")
    total = 0
    seen = set()
    i = 0
    while i < len(s):
        j = i
        while j < len(s) and s[j].isdigit():
            j += 1
        if j == i:
            raise ValueError("expected number")
        num = int(s[i:j])
        if j >= len(s):
            raise ValueError("missing unit")
        unit = s[j]
        if unit in seen:
            raise ValueError(f"duplicate unit: {unit}")
        seen.add(unit)
        i = j + 1
        if unit == 'h':
            total += num * 3600
        elif unit == 'm':
            total += num * 60
        elif unit == 's':
            total += num
        else:
            raise ValueError(f"invalid unit: {unit}")
    return total

Ahora ejecutamos un test harness diferencial que alimenta las mismas entradas a los tres y marca las discrepancias.

def differential_test(implementations, inputs):
    for case in inputs:
        results = []
        errors = []
        for impl in implementations:
            try:
                results.append(impl(case))
            except Exception as e:
                errors.append(type(e).__name__)
        if errors:
            if len(errors) == len(implementations) and len(set(errors)) == 1:
                print(f"ALL ERROR on {case!r}: {errors[0]}")
            else:
                print(f"MIXED on {case!r}: results={results}, errors={errors}")
        else:
            if len(set(results)) == 1:
                print(f"AGREE on {case!r}: {results[0]}")
            else:
                print(f"DISAGREE on {case!r}: {results}")

IMPLS = [parse_duration_a, parse_duration_b, parse_duration_c]

CASES = [
    "1h30m",      # normal
    "90m",        # single unit
    "30m1h",      # out of order
    "1h2h",       # duplicate unit
    "0h",         # zero is not positive
    "1.5h",       # decimal
    "1H",         # wrong case
]

differential_test(IMPLS, CASES)

Ejecutar esto produce:

AGREE on '1h30m': 5400
AGREE on '90m': 5400
MIXED on '30m1h': results=[5400, 5400], errors=['ValueError']
MIXED on '1h2h': results=[10800], errors=['ValueError', 'ValueError']
AGREE on '0h': 0
ALL ERROR on '1.5h': ValueError
ALL ERROR on '1H': ValueError

El harness detecta problemas reales. En 30m1h, la regex del Equipo A rechaza la entrada out-of-order mientras que los Equipos B y C la aceptan. En 1h2h, el scanner del Equipo B suma silenciosamente ambas horas mientras que la detección de duplicados del Equipo C lanza un error. Estos son exactamente los bugs que el testing diferencial se supone que debe encontrar.

Pero mira 0h. La especificación decía “positive integer”. Cero no es positivo. Las tres implementaciones lo aceptan porque ninguno de los equipos escribió validación para un requisito que sonaba obvio pero no estaba reforzado. Coinciden, así que la prueba pasa. Este es un common-mode failure oculto a plena vista.

Lo mismo ocurre con 1H. Los tres lo rechazan porque la especificación mostraba unidades en minúsculas. Pero si la especificación pretendía case-insensitive matching, cada implementación está equivocada y están equivocadas juntas.

Cómo hacer que el testing diferencial sea menos erróneo

No puedes eliminar los common-mode failures por completo sin una prueba formal. Pero puedes hacer que sean menos probables.

Diversifica las estrategias de implementación, no solo el personal. Si cada equipo usa el mismo algoritmo del mismo libro de texto, no has construido diversidad. Has construido latencia. Obliga a un equipo a usar una state machine, a otro a usar un parser generator, a un tercero a usar recursión. Diferentes algoritmos fallan en diferentes entradas.

Usa diferentes lenguajes de programación. Los bugs de la biblioteca estándar compartida son un common-mode failure clásico. Si cada implementación usa el mismo parser de JSON o la misma biblioteca de matemáticas de punto flotante, comparten sus bugs.

Añade un adversarial oracle. Asigna a alguien para que encuentre entradas donde la especificación sea ambigua. Su trabajo es hacer que las implementaciones discrepen. Las entradas que las dividen son las pruebas más valiosas que escribirás.

Haz fuzzing agresivamente. El acuerdo en un puñado de ejemplos seleccionados a mano es una evidencia débil. El acuerdo en un millón de entradas generadas aleatoriamente es más fuerte. El fuzzing descubre las esquinas del espacio de entrada que ninguno de los equipos consideró.

Prueba la propia especificación. Escribe negative tests explícitos para requisitos como “positive integer” y verifica que al menos una implementación los rechace. Si las tres aceptan una entrada inválida, tu especificación necesita ajustarse, no tu código.

Cuándo el testing diferencial es suficiente

El testing diferencial no es un sustituto de la formal verification. Es un filtro. Detecta bugs de implementación de forma barata y temprana, antes de que hayas invertido en una prueba.

La pregunta no es si puedes confiar en él. La pregunta es para qué puedes confiar en él. Puedes confiar en él para encontrar discrepancias. No puedes confiar en él para encontrar acuerdo universal en presencia de una mala especificación.

Si estás construyendo software safety-critical, el testing diferencial es un paso preliminar. Ejecútalo, corrige las discrepancias, luego somete la especificación a un model checker o a un proof assistant. Si estás construyendo un web service, el testing diferencial puede ser toda la confianza que necesitas para una feature branch. La prueba es proporcional a lo que está en juego.

Si hoy estás ejecutando un N-version test suite, añade un caso de prueba más. Encuentra una entrada que la especificación no defina claramente. Ejecútala a través de tus implementaciones. Si todas coinciden, no has encontrado una buena prueba. Has encontrado un agujero en la especificación.

Los bugs que te matan no son aquellos en los que tus implementaciones discrepan. Son aquellos en los que coinciden por la razón equivocada.


Preguntas frecuentes

¿Qué es el testing diferencial?

El testing diferencial es una técnica en la que múltiples implementaciones independientes de la misma especificación se ejecutan con entradas idénticas. Sus salidas se comparan. Las discrepancias revelan bugs sin requerir una fuente de verdad preexistente para cada entrada.

¿Qué es un common-mode failure en software?

Un common-mode failure ocurre cuando múltiples componentes independientes fallan en la misma entrada por la misma razón subyacente. En la N-version programming, esto suele ocurrir cuando la especificación es ambigua y cada equipo resuelve la ambigüedad de la misma manera.

¿En qué se diferencia el testing diferencial del property-based testing?

El property-based testing verifica que las salidas satisfagan reglas generales, como “ordenar una lista nunca cambia su longitud”. El testing diferencial verifica que múltiples implementaciones produzcan la misma salida. Las dos técnicas se complementan entre sí. El property-based testing encuentra errores de lógica. El testing diferencial encuentra inconsistencias.

¿Puedo usar LLMs para generar implementaciones diversas para testing diferencial?

Puedes, pero ten cuidado. Los modelos entrenados en corpus superpuestos tienden a producir código con modos de fallo correlacionados. Investigaciones recientes sugieren tasas de co-error entre el 15% y el 30% para componentes generados por IA. Si usas LLMs, solicita algoritmos y lenguajes genuinamente diferentes. Una colección de reescrituras semánticamente idénticas no es diversidad.