Ce que le differential testing vous apporte réellement

Vous pouvez faire confiance au differential testing sans preuve formelle, mais seulement si vous comprenez exactement où il s’effondre.

La faiblesse s’appelle la common-mode failure. Quand chaque implémentation d’une spécification fait la même mauvaise supposition, elles sont toutes d’accord et votre test harness considère que c’est un pass. La N-version programming ne vous protège pas contre une mauvaise spec.

Le differential testing fonctionne en exécutant plusieurs implémentations indépendantes de la même spécification avec la même entrée. Si leurs sorties divergent, au moins une est boguée. Si elles sont d’accord, vous considérez provisoirement que c’est bon.

C’est puissant car cela élimine le besoin d’un test oracle. Un oracle est une source de vérité qui connaît la réponse correcte pour chaque entrée. Pour les systèmes complexes, les oracles sont souvent plus difficiles à construire que le système lui-même. Un moteur fiscal, une simulation physique ou un décodeur de protocole peuvent être testés pour leur cohérence bien avant que vous ne puissiez prouver ce que chaque sortie devrait être.

Mais la nature provisoire compte. L’accord prouve seulement la cohérence. Il ne prouve pas la correction.

Pourquoi l’accord n’est pas la correction

Le mode de défaillance que tout le monde apprend en théorie mais oublie en pratique est la common-mode fault. Quand l’erreur provient de la spécification elle-même, ou d’une supposition partagée que toutes les équipes d’implémentation font indépendamment, chaque version produit la même mauvaise réponse.

La spec n’a pas besoin d’être fausse de manière évidente. Elle doit juste être ambiguë sur un cas limite que les cerveaux humains résolvent de la même façon.

Imaginez une spécification pour une fonction qui calcule l’aire d’un polygone simple à partir d’une liste de sommets. La spec fournit la shoelace formula. Elle ne mentionne jamais l’ordre des sommets.

Trois équipes l’implémentent. Les trois supposent un ordre antihoraire car c’est ainsi que le diagramme d’exemple est dessiné. Les entrées dans le sens horaire produisent une aire négative dans la formule brute. Les trois encapsulent discrètement le résultat avec abs() car l’aire doit être positive. Elles sont d’accord sur chaque cas de test.

Mais la spec n’a jamais dit que le sens horaire était invalide. Les implémentations sont cohérentes et fausses par omission. Le differential testing donne le feu vert à toutes.

Trois implémentations, une spec ambiguë

Voici un exemple concret que vous pouvez exécuter. La spécification dit : « Analyser une chaîne de durée et retourner le nombre total de secondes. Une durée se compose d’un ou plusieurs composants. Chaque composant est un entier positif suivi d’une lettre d’unité : h pour les heures, m pour les minutes, s pour les secondes. »

Trois équipes reçoivent cette spec et écrivent leurs propres 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

Maintenant nous exécutons un test harness differential qui fournit les mêmes inputs aux trois et signale les désaccords.

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)

L’exécution produit :

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

Le test harness détecte de vrais problèmes. Sur 30m1h, la regex de l’équipe A rejette l’entrée hors ordre tandis que les équipes B et C l’acceptent. Sur 1h2h, le scanner de l’équipe B ajoute silencieusement les deux heures tandis que la détection de doublons de l’équipe C lève une erreur. Ce sont exactement les bugs que le differential testing est censé trouver.

Mais regardez 0h. La spécification disait « entier positif ». Zéro n’est pas positif. Les trois implémentations l’acceptent car aucune des équipes n’a écrit de validation pour une exigence qui semblait évidente mais n’était pas appliquée. Elles sont d’accord, donc le test passe. C’est une common-mode failure qui se cache en pleine vue.

La même chose se produit avec 1H. Les trois le rejettent car la spec montrait des unités en minuscules. Mais si la spec prévoyait une correspondance insensible à la casse, chaque implémentation est fausse et elles le sont ensemble.

Comment rendre le differential testing moins faux

Vous ne pouvez pas éliminer entièrement les common-mode failures sans preuve formelle. Mais vous pouvez les rendre moins probables.

Diversifiez les stratégies d’implémentation, pas seulement le personnel. Si chaque équipe utilise le même algorithme du même manuel, vous n’avez pas construit de diversité. Vous avez construit de la latence. Forcez une équipe à utiliser une state machine, une autre à utiliser un parser generator, une troisième à utiliser la récursion. Différents algorithmes échouent sur différentes entrées.

Utilisez différents programming languages. Les bugs de standard library partagée sont une common-mode failure classique. Si chaque implémentation utilise le même JSON parser ou la même bibliothèque de mathématiques en floating-point, elles partagent ses bugs.

Ajoutez un adversarial oracle. Assignez quelqu’un pour trouver des inputs où la spécification est ambiguë. Son travail consiste à faire diverger les implémentations. Les inputs qui les séparent sont les tests les plus précieux que vous écrirez.

Fuzzez agressivement. L’accord sur une poignée d’exemples choisis manuellement est une preuve faible. L’accord sur un million d’inputs générés aléatoirement est plus fort. Le fuzzing découvre les coins de l’input space qu’aucune des équipes n’a considérés.

Testez la spécification elle-même. Écrivez des negative tests explicites pour des exigences comme « entier positif » et vérifiez qu’au moins une implémentation les rejette. Si les trois acceptent une entrée invalide, votre spec a besoin d’être resserrée, pas votre code.

Quand le differential testing suffit

Le differential testing ne remplace pas la formal verification. C’est un filtre. Il détecte les bugs d’implémentation à bon marché et tôt, avant que vous n’ayez investi dans une preuve.

La question n’est pas de savoir si vous pouvez lui faire confiance. La question est de savoir à quoi vous pouvez lui faire confiance. Vous pouvez lui faire confiance pour trouver des désaccords. Vous ne pouvez pas lui faire confiance pour trouver un accord universel en présence d’une mauvaise spec.

Si vous construisez un logiciel safety-critical, le differential testing est une étape préliminaire. Exécutez-le, corrigez les désaccords, puis soumettez la spécification à un model checker ou un proof assistant. Si vous construisez un web service, le differential testing peut être toute la confiance dont vous avez besoin pour une feature branch. La preuve est proportionnelle aux enjeux.

Si vous exécutez une N-version test suite aujourd’hui, ajoutez un cas de test de plus. Trouvez une entrée que la spécification ne définit pas clairement. Faites-la passer dans vos implémentations. Si elles sont toutes d’accord, vous n’avez pas trouvé un bon test. Vous avez trouvé un trou dans la spécification.

Les bugs qui vous tuent ne sont pas ceux où vos implémentations divergent. Ce sont ceux où elles sont d’accord pour la mauvaise raison.


Questions fréquemment posées

Qu’est-ce que le differential testing ?

Le differential testing est une technique où plusieurs implémentations indépendantes de la même spécification sont exécutées avec des inputs identiques. Leurs sorties sont comparées. Les désaccords révèlent des bugs sans nécessiter de source de vérité préexistante pour chaque entrée.

Qu’est-ce qu’une common-mode failure en logiciel ?

Une common-mode failure se produit lorsque plusieurs composants indépendants échouent sur la même entrée pour la même raison sous-jacente. En N-version programming, cela arrive généralement quand la spécification est ambiguë et que chaque équipe résout l’ambiguïté de la même manière.

En quoi le differential testing diffère-t-il du property-based testing ?

Le property-based testing vérifie que les sorties satisfont des règles générales, comme « trier une liste ne change jamais sa longueur ». Le differential testing vérifie que plusieurs implémentations produisent la même sortie. Les deux techniques se complètent. Le property-based testing trouve des erreurs de logique. Le differential testing trouve des inconsistances.

Puis-je utiliser des LLMs pour générer des implémentations diverses pour le differential testing ?

Vous pouvez, mais soyez prudent. Les modèles entraînés sur des corpus qui se chevauchent ont tendance à produire du code avec des modes de défaillance corrélés. Des recherches récentes suggèrent des taux de co-erreur entre 15 % et 30 % pour les composants générés par IA. Si vous utilisez des LLMs, demandez explicitement des algorithmes et des langages vraiment différents. Une collection de réécritures sémantiquement identiques n’est pas de la diversité.