Vous avez écrit douze metamorphic relations pour votre moteur de tarification. Chaque test réussit. Vous vous sentez bien au sujet de votre couverture.

Puis un client signale que les remises en gros sont calculées à l’envers. Vous vérifiez votre suite de relations. Pas un seul test n’a échoué. Vous aviez des relations pour la cohérence additive, la monotonicité, et l’idempotence. Aucune d’elles n’a détecté une erreur de signe dans le multiplicateur de remise.

C’est le sale secret du metamorphic testing : avoir des relations n’est pas la même chose qu’avoir des relations utiles. Une metamorphic relation faible est pire qu’aucun test du tout, parce qu’elle vous convainc que votre code est correct quand il ne l’est pas.

Qu’est-ce qui fait qu’une relation est « bonne » ?

Une bonne metamorphic relation a une capacité élevée de détection de défaillances. Elle détecte de vrais bugs que les programmeurs écrivent réellement. Le reste n’est que surcoût.

L’exemple classique est de tester une fonction de moyenne avec un bug off-by-one :

def buggy_mean(values):
    """Compute the arithmetic mean."""
    return sum(values) / (len(values) - 1)  # bug: off-by-one in denominator

Cela semble plausible si vous êtes habitué aux formules de variance d’échantillon. C’est aussi faux. Voici quatre relations que les gens écrivent couramment pour une fonction de moyenne, et ce que chacune attrape réellement :

  1. Bornage : la moyenne se situe entre le min et le max. Faible. La moyenne buggée satisfait toujours cela pour la plupart des entrées.

  2. Idempotence sur les constantes : mean([c] * n) == c. Moyenne. Elle attrape le bug pour les listes constantes, mais des données aléatoires déclenchent rarement un échec.

  3. Invariance par translation : mean([x + c for x in values]) == mean(values) + c. Forte. Le dénominateur buggé casse cela pour presque chaque entrée non vide.

  4. Mise à l’échelle : mean([x * k for x in values]) == mean(values) * k. Forte. Même raison. L’erreur off-by-one survit à la mise à l’échelle dans exactement zéro cas intéressants.

Si votre suite de tests ne vérifiait que le bornage et l’idempotence sur les constantes, l’erreur off-by-one se retrouverait en production. Vous auriez des metamorphic tests. Vous n’auriez pas de détection de bugs.

Relations fortes vs. relations faibles

La différence entre une relation forte et une relation faible n’est pas à quel point elle semble astucieuse. C’est combien de classes de fautes elle élimine.

Une relation faible vérifie une propriété que la plupart des implémentations incorrectes satisfont de toute façon accidentellement. Le bornage est un exemple parfait. La plupart des bugs arithmétiques préservent le bornage parce que l’addition et la multiplication n’inventent pas spontanément des valeurs en dehors de la plage d’entrée. Une relation qui passe pour du code cassé est du théâtre.

Une relation forte encode une contrainte structurelle que les implémentations cassées violent. L’invariance par translation est forte parce qu’elle lie la transformation d’entrée à la transformation de sortie par une égalité précise. Il n’y a pas de marge de manœuvre.

Vous pouvez mesurer cela formellement. Dans la recherche sur le metamorphic testing, la subsumption de relation signifie que la relation A détecte chaque faute que la relation B détecte, plus certaines. Si A subsume B, alors B est redondante. Vous devriez garder A et supprimer B.

En pratique, vous n’avez pas besoin de la preuve formelle. Vous avez besoin de l’intuition : si une relation passerait encore après que vous ayez délibérément introduit un bug plausible, elle est faible. Jetez-la.

Les bonnes relations couvrent différents domaines de fautes

Une relation forte ne suffit pas. Une seule relation attrape une classe d’erreurs. Les vrais programmes contiennent plusieurs types de bugs indépendants, et votre ensemble de relations doit les couvrir.

Considérez une fonction de tri. Voici des relations classées par ce qu’elles attrapent :

Permutation : la sortie contient exactement les mêmes éléments que l’entrée. Attrape les bugs de suppression/duplication. Rate les bugs d’ordonnancement.

Ordre : la sortie est non décroissante. Attrape les bugs de comparaison. Rate les bugs de permutation.

Idempotence : sort(sort(x)) == sort(x). N’attrape que des implémentations véritablement cassées qui détruisent l’ordonnancement. Presque inutile.

Stabilité : si vous associez chaque élément à son index original, les clés égales restent dans l’ordre d’entrée. Attrape les opérateurs de comparaison qui utilisent >= au lieu de >.

Sous-structure : trier un préfixe puis la liste complète devrait s’accorder sur l’ordre du préfixe. Attrape les bugs de terminaison anticipée.

Une suite de tests avec seulement permutation et idempotence raterait un tri qui retourne toujours [1, 2, 3]. Une suite avec permutation et ordre attrape ce bug. Ajoutez la stabilité et vous attrapez aussi les tris instables.

L’important n’est pas de collecter autant de relations que possible. L’important est de couvrir des modes de défaillance indépendants. Deux relations qui attrapent le même bug sont pires qu’une relation qui attrape un bug différent.

Le compromis : les relations plus fortes sont plus difficiles à trouver

Il y a une raison pour laquelle les équipes écrivent des relations faibles. Les relations fortes nécessitent une connaissance du domaine. Vous devez comprendre la structure mathématique de votre problème assez bien pour encoder un invariant non évident.

Pour la fonction de moyenne, l’invariance par translation est évidente pour quiconque a une formation en statistiques. Pour une simulation de particules, la relation équivalente pourrait nécessiter de savoir que la dynamique hamiltonienne préserve le volume de l’espace des phases. Toutes les équipes n’ont pas cette expertise sous la main.

L’autre coût est le débogage. Quand une relation forte échoue, la violation vous dit qu’une propriété structurelle s’est cassée, mais le bug pourrait être n’importe où dans la chaîne de raisonnement qui a mené à cette propriété. Une relation faible comme « la longueur de sortie égale la longueur d’entrée » échoue d’exactement une manière. Une relation forte comme « la transformée de Fourier d’un signal décalé acquiert un terme de phase linéaire » échoue de cent manières, et traquer laquelle est votre bug prend plus de temps.

C’est la tension centrale. Les relations faibles sont faciles à écrire, faciles à déboguer, et globalement inutiles. Les relations fortes sont difficiles à écrire, difficiles à déboguer, et trouvent réellement des bugs. Il n’y a pas de repas gratuit.

Comment évaluer une metamorphic relation

Avant d’ajouter une relation à votre suite de tests, soumettez-la à trois vérifications :

Le test de bug délibéré. Introduisez un bug réaliste dans votre implémentation. La relation échoue-t-elle ? Si non, la relation ne tire pas son poids. Essayez une erreur de signe, une erreur off-by-one, des arguments échangés, une condition aux limites manquante. Ce sont les bugs qui arrivent en production. Vos relations devraient les attraper.

Le test d’indépendance. Regardez vos relations existantes. L’une d’elles attraperait-elle le même bug ? Si oui, cette nouvelle relation est redondante. La redondance n’est pas de la sécurité. C’est un fardeau de maintenance sans bénéfice marginal.

Le test de falsifiabilité. Pouvez-vous imaginer une implémentation cassée plausible qui satisfait la relation ? Si vous pouvez en esquisser une en trente secondes, la relation est trop faible. Une bonne relation devrait ressembler à une contrainte stricte, pas à une suggestion vague.

Voici ce que cela donne en code pour la fonction de moyenne :

import random

def mean(values):
    return sum(values) / len(values)

def test_translation_invariance():
    values = [random.uniform(-100, 100) for _ in range(20)]
    c = 5.5
    shifted = [x + c for x in values]
    assert mean(shifted) == mean(values) + c

def test_scaling():
    values = [random.uniform(-50, 50) for _ in range(20)]
    k = 3.0
    scaled = [x * k for x in values]
    assert mean(scaled) == mean(values) * k

Maintenant introduisez le bug off-by-one. Changez len(values) en len(values) - 1. Exécutez les deux tests. L’invariance par translation échoue immédiatement. La mise à l’échelle échoue immédiatement. Le bornage passerait probablement.

C’est la différence entre une relation qui mérite sa place dans votre suite et une qui occupe simplement des lignes.

Commencez par les classes de fautes, pas les propriétés

L’erreur que la plupart des équipes commettent est de faire d’abord un brainstorming de propriétés. Elles demandent, « Quels invariants cette fonction a-t-elle ? » Cela produit des relations faibles, parce que les invariants sont faciles à énoncer et difficiles à violer.

Au lieu de cela, commencez par les classes de fautes. Demandez, « Quels bugs un programmeur fatigué écrirait-il dans cette fonction ? » Puis trouvez des relations qui attrapent ces bugs.

Pour une fonction de distance géométrique, les bugs probables sont les erreurs de signe, les confusions d’unités, et les incompatibilités de dimensions. Une relation qui vérifie que la distance est non négative attrape les erreurs de signe. Une relation qui vérifie la mise à l’échelle sous les transformations de coordonnées attrape les confusions d’unités. Une relation qui vérifie l’inégalité triangulaire attrape les absurdités de dimension.

Si vous ne pouvez pas nommer le bug qu’une relation attrape, vous n’avez pas besoin de cette relation.

Les relations sont une ressource rare. Dépensez-les sagement.

Le metamorphic testing ne concerne pas les métriques de couverture. Il s’agit de confiance. Une relation forte qui attrape de vrais bugs vaut plus que vingt relations faibles qui passent pour du code cassé.

Auditez vos metamorphic tests existants. Introduisez un bug. Voyez ce qui échoue. Supprimez ce qui n’échoue pas. Puis ajoutez une relation pour chaque classe de fautes dont vous vous inquiétez réellement. C’est une suite de tests qui mérite sa place.