O Que Differential Testing Realmente Te Dá

Você pode confiar em differential testing sem uma prova formal, mas apenas se entender exatamente onde ele falha.

A fraqueza se chama common-mode failure. Quando toda implementação de uma especificação faz a mesma suposição errada, todas concordam e seu test harness marca como pass. N-version programming não te protege contra uma especificação ruim.

Differential testing funciona executando múltiplas implementações independentes da mesma especificação com a mesma entrada. Se as saídas discordarem, pelo menos uma está com bug. Se concordarem, você provisoriamente considera boa.

Isso é poderoso porque remove a necessidade de um test oracle. Um oracle é uma fonte de verdade que sabe a resposta correta para cada entrada. Para sistemas complexos, oracles costumam ser mais difíceis de construir que o próprio sistema. Um motor de impostos, uma simulação de física ou um decodificador de protocol podem ser testados por consistência muito antes de você poder provar o que cada saída deveria ser.

Mas a provisoriedade importa. Concordância só prova consistência. Não prova corretude.

Por Que Concordância Não É Corretude

O modo de falha que todos aprendem em teoria mas esquecem na prática é o common-mode fault. Quando o erro se origina na própria especificação, ou em uma suposição compartilhada que todos os times de implementação fazem independentemente, cada versão produz a mesma resposta errada.

A especificação não precisa estar errada de forma óbvia. Ela só precisa ser ambígua em um edge case que cérebros humanos resolvem da mesma forma.

Imagine uma especificação para uma função que calcula a área de um polígono simples a partir de uma lista de vértices. A especificação fornece a shoelace formula. Ela nunca menciona a ordenação dos vértices.

Três times a implementam. Todos três assumem ordenação counter-clockwise porque é assim que o diagrama de exemplo está desenhado. Entradas clockwise produzem área negativa na fórmula bruta. Todos três silenciosamente envolvem o resultado com abs() porque área deve ser positiva. Eles concordam em todo caso de teste.

Mas a especificação nunca disse que clockwise era inválido. As implementações são consistentes e erradas por omissão. Differential testing aprova todas.

Três Implementações, Uma Especificação Ambígua

Aqui está um exemplo concreto que você pode executar. A especificação diz: “Analise uma string de duração e retorne o número total de segundos. Uma duração consiste em um ou mais componentes. Cada componente é um inteiro positivo seguido de uma letra de unidade: h para horas, m para minutos, s para segundos.”

Três times recebem essa especificação e escrevem seus próprios 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

Agora executamos um differential test harness que alimenta as mesmas entradas nas três e sinaliza discordâncias.

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)

Executar isso produz:

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

O harness captura problemas reais. Em 30m1h, a regex do Time A rejeita a entrada fora de ordem enquanto os Times B e C aceitam. Em 1h2h, o scanner do Time B silenciosamente adiciona ambas as horas enquanto a detecção de duplicatas do Time C levanta um erro. Esses são exatamente os bugs que differential testing deveria encontrar.

Mas olhe para 0h. A especificação dizia “positive integer.” Zero não é positivo. Todas as três implementações aceitam porque nenhum dos times escreveu validação para um requisito que parecia óbvio mas não era imposto. Eles concordam, então o teste passa. Esse é um common-mode failure escondido à vista.

A mesma coisa acontece com 1H. Todos três rejeitam porque a especificação mostrou unidades em lowercase. Mas se a especificação pretendia case-insensitive matching, toda implementação está errada e errada juntas.

Como Tornar o Differential Testing Menos Errado

Você não pode eliminar common-mode failures completamente sem uma prova formal. Mas pode torná-las menos prováveis.

Diversifique estratégias de implementação, não apenas pessoal. Se todo time usa o mesmo algoritmo do mesmo livro-texto, você não construiu diversidade. Você construiu latência. Force um time a usar uma state machine, outro a usar um parser generator, um terceiro a usar recursão. Algoritmos diferentes falham em entradas diferentes.

Use diferentes linguagens de programação. Bugs compartilhados de standard library são um common-mode failure clássico. Se toda implementação usa o mesmo parser JSON ou a mesma biblioteca de matemática de floating-point, elas compartilham seus bugs.

Adicione um adversarial oracle. Designe alguém para encontrar entradas onde a especificação é ambígua. O trabalho deles é fazer as implementações discordarem. As entradas que as dividem são os testes mais valiosos que você escreverá.

Fuzz agressivamente. Concordância em um punhado de exemplos escolhidos à mão é evidência fraca. Concordância em um milhão de entradas geradas aleatoriamente é mais forte. Fuzzing descobre os cantos do espaço de entrada que nenhum dos times considerou.

Teste a própria especificação. Escreva testes negativos explícitos para requisitos como “positive integer” e verifique que pelo menos uma implementação os rejeita. Se todas as três aceitam uma entrada inválida, sua especificação precisa de aperto, não seu código.

Quando Differential Testing É Suficiente

Differential testing não é um substituto para formal verification. É um filtro. Ele captura bugs de implementação de forma barata e precoce, antes de você investir em uma prova.

A questão não é se você pode confiar nele. A questão é para que você pode confiar nele. Você pode confiar nele para encontrar discordâncias. Não pode confiar nele para encontrar concordância universal na presença de uma especificação ruim.

Se você está construindo software safety-critical, differential testing é um passo preliminar. Execute-o, corrija as discordâncias, depois submeta a especificação a um model checker ou a um proof assistant. Se você está construindo um web service, differential testing pode ser toda a confiança que você precisa para uma feature branch. A prova é proporcional ao que está em jogo.

Se você está executando uma N-version test suite hoje, adicione mais um caso de teste. Encontre uma entrada que a especificação não define claramente. Execute-a através de suas implementações. Se todas concordam, você não encontrou um bom teste. Você encontrou um buraco na especificação.

Os bugs que te matam não são aqueles onde suas implementações discordam. São aqueles onde elas concordam pelo motivo errado.


Perguntas Frequentes

O que é differential testing?

Differential testing é uma técnica onde múltiplas implementações independentes da mesma especificação são executadas com entradas idênticas. Suas saídas são comparadas. Discordâncias revelam bugs sem exigir uma fonte de verdade pré-existente para cada entrada.

O que é um common-mode failure em software?

Um common-mode failure ocorre quando múltiplos componentes independentes falham na mesma entrada pelo mesmo motivo subjacente. Em N-version programming, isso geralmente acontece quando a especificação é ambígua e cada time resolve a ambiguidade da mesma forma.

Como differential testing difere de property-based testing?

Property-based testing verifica que as saídas satisfazem regras gerais, como “ordenar uma lista nunca muda seu comprimento.” Differential testing verifica que múltiplas implementações produzem a mesma saída. As duas técnicas se complementam. Property-based testing encontra erros de lógica. Differential testing encontra inconsistências.

Posso usar LLMs para gerar implementações diversas para differential testing?

Você pode, mas tenha cuidado. Modelos treinados em corpora sobrepostos tendem a produzir código com modos de falha correlacionados. Pesquisas recentes sugerem taxas de co-erro entre 15% e 30% para componentes gerados por IA. Se você usar LLMs, peça por algoritmos e linguagens genuinamente diferentes. Uma coleção de reescritas semanticamente idênticas não é diversidade.