Le metamorphic testing a trouvé 147 bugs confirmés dans GCC et LLVM, des défauts dans des simulateurs ADAS commerciaux utilisés par des équipementiers automobiles, et une faille fatale dans un système de perception de voiture autonome huit jours avant qu’elle ne tue un piéton. La technique a l’air académique, mais les bugs ne le sont pas.
Le problème est le oracle problem. Pour de nombreux programmes, vous pouvez exécuter des entrées mais vous ne pouvez pas vérifier indépendamment que les sorties sont correctes. Quel est le plus court chemin exact à travers un réseau routier de 10 000 nœuds ? Cette optimisation de compilateur préserve-t-elle la sémantique ? La classification de ce modèle ML est-elle réellement correcte ? Vous ne le savez pas. Les tests unitaires traditionnels s’effondrent ici parce que vous ne pouvez pas écrire un assertEquals(expected, actual) quand vous n’avez aucune idée de ce que devrait être expected.
Le metamorphic testing contourne ce problème en ne vérifiant pas du tout les sorties. Il vérifie les relations entre les sorties.
Qu’est-ce que le metamorphic testing ?
Le metamorphic testing est une technique où vous transformez une entrée en une entrée liée, exécutez les deux dans votre programme, et vérifiez que les deux sorties obéissent à une relation mathématique ou logique connue. Cette relation s’appelle une metamorphic relation.
Si votre programme calcule la moyenne d’une liste de nombres, vous n’avez pas besoin de connaître la moyenne exacte de [4.2, 1.7, 9.3, 2.1] pour le tester. Vous avez seulement besoin de savoir que mélanger la liste devrait produire le même résultat, ou que doubler chaque élément devrait doubler la moyenne. Ce sont des metamorphic relations.
La première entrée est le source test case. L’entrée transformée est le follow-up test case. L’oracle est la relation elle-même.
Voici un exemple concret en Python :
import random
def compute_average(numbers):
"""Returns the arithmetic mean of a list of numbers."""
if not numbers:
raise ValueError("empty list")
return sum(numbers) / len(numbers)
def test_average_permutation_invariant():
"""MR-1: Shuffling the input should not change the average."""
source = [4.2, 1.7, 9.3, 2.1, 5.6]
follow_up = source.copy()
random.shuffle(follow_up)
source_out = compute_average(source)
follow_up_out = compute_average(follow_up)
assert source_out == follow_up_out, (
f"Permutation MR failed: {source_out} != {follow_up_out}"
)
def test_average_scaling():
"""MR-2: Doubling every element should double the average."""
source = [3.0, 6.0, 9.0]
follow_up = [x * 2 for x in source]
source_out = compute_average(source)
follow_up_out = compute_average(follow_up)
assert follow_up_out == source_out * 2, (
f"Scaling MR failed: {follow_up_out} != {source_out * 2}"
)
def test_average_inclusion():
"""MR-3: Appending the average itself should not decrease the average."""
source = [10.0, 20.0, 30.0]
source_out = compute_average(source)
follow_up = source + [source_out]
follow_up_out = compute_average(follow_up)
assert follow_up_out == source_out, (
f"Inclusion MR failed: {follow_up_out} != {source_out}"
)
if __name__ == "__main__":
test_average_permutation_invariant()
test_average_scaling()
test_average_inclusion()
print("All metamorphic relations passed.")
Si l’une de ces relations échoue, vous avez trouvé un bug sans jamais calculer la moyenne attendue à la main. C’est l’idée fondamentale.
De vrais bugs trouvés dans des systèmes de production
La technique n’est pas théorique. Voici des cas documentés où le metamorphic testing a trouvé de vrais bugs dans des logiciels de production.
147 bugs dans GCC et LLVM
Des chercheurs ont appliqué le metamorphic testing aux pipelines d’optimisation de compilateurs C et ont trouvé 147 bugs confirmés dans GCC et LLVM. Ce n’étaient pas des programmes jouets. C’étaient de vrais bugs de mauvaise compilation où un programme C correct, lorsqu’il passait par un compilateur optimisant, produisait du code machine incorrect. Certains de ces bugs existaient depuis des années. Les metamorphic relations étaient simples : si vous inlinez une fonction à la main, la sortie optimisée devrait se comporter comme l’original. Si vous permutez des instructions indépendantes, le résultat ne devrait pas changer. Les développeurs de compilateurs ont confirmé et corrigé ces bugs.
Les compilateurs de shaders Vulkan chez Google
L’équipe GraphicsFuzz de Google a mis en production des tests de metamorphic testing randomisés pour la Khronos Vulkan Conformance Test Suite. Ils ont généré des fragment shaders aléatoires, appliqué des transformations préservant la sémantique (comme envelopper des expressions dans des fonctions identité ou ajouter du dead code), et comparé les images rendues entre différents compilateurs et GPU. Quand deux shaders supposément équivalents produisaient des pixels différents, ils avaient trouvé un bug de compilateur. L’équipe a construit un pipeline entier appelé gfauto pour réduire, dédupliquer et signaler ces cas. Ils ont trouvé des bugs dans l’écosystème d’outils qui transforment, optimisent et valident les shaders Vulkan, y compris des pilotes de production livrés aux utilisateurs finaux.
Les simulateurs ADAS utilisés par des équipementiers automobiles
Une équipe a testé trois plateformes de simulation ADAS populaires, Simulink, CarMaker et 51Sim-One Cloud, en se concentrant sur leurs Lane Keeping Assist Systems. Les cas de test ordinaires passaient sur les trois plateformes. Aucun problème. Puis l’équipe a appliqué des metamorphic relations géométriques : refléter la scène routière horizontalement, faire pivoter la position du véhicule, appliquer des transformations affines aux marquages de voie. Les sorties devaient se transformer de manière prévisible. Ce n’était pas le cas. Des bugs ont été révélés dans les trois plateformes. MathWorks et IPG Automotive ont ensuite confirmé les problèmes. Ce sont les mêmes plateformes utilisées pour valider les logiciels avant qu’ils n’entrent dans les véhicules.
Le défaut du self-driving car dans la même classe
Dans l’un des cas les plus sobres, des chercheurs ont appliqué le metamorphic testing à un système de détection d’objets pour véhicules autonomes et ont trouvé un bug dans le pipeline de perception. Le système ne classifiait pas correctement les piétons sous des entrées transformées spécifiques. Ils l’ont signalé. Le bug qu’ils avaient trouvé appartenait à la même classe de défauts que celle impliquée dans des collisions mortelles de self-driving car avec des piétons.
Le compromis : les relations sont spécifiques au domaine
Le metamorphic testing est puissant, mais il n’est pas gratuit. La partie difficile consiste à identifier de bonnes metamorphic relations. Une mauvaise relation vous donne une fausse confiance. Une relation trop faible ne détectera pas les bugs. Une relation trop forte échouera sur un comportement correct à cause du bruit en virgule flottante ou de la non-déterminisme.
Concevoir des relations exige des connaissances du domaine. Pour un algorithme de plus court chemin, de bonnes relations incluent : le coût du chemin A->B devrait être égal à B->A dans un graphe non orienté ; ajouter une constante à chaque poids d’arête devrait ajouter cette constante multipliée par le nombre d’arêtes au coût total du chemin. Pour un algorithme de tri : inverser une liste triée et la trier devrait donner l’inverse de la liste triée originale ; chaque élément de la sortie devrait apparaître dans l’entrée avec la même fréquence.
Vous ne pouvez pas réutiliser les mêmes relations dans des systèmes sans rapport. C’est le coût.
L’arithmétique en virgule flottante est un autre piège. De nombreuses relations supposent une égalité exacte, mais 0.1 + 0.2 != 0.3 en IEEE 754. Vous avez besoin de comparaisons basées sur une tolérance, et choisir la bonne tolérance est un problème en soi. Trop stricte et vous obtenez des faux positifs. Trop laxiste et vous manquez de vrais bugs.
Comment ajouter le metamorphic testing à votre codebase
Vous n’avez pas besoin d’un framework. Vous avez besoin de discipline.
Commencez par les fonctions de votre codebase qui n’ont pas d’oracle. L’inférence ML, les algorithmes d’optimisation, les calculs géométriques, les agrégations statistiques et le code de simulation sont tous des candidats. Pour chacun, demandez-vous : qu’est-ce qui doit être vrai à propos de la sortie si je change l’entrée d’une manière spécifique et prévisible ?
Écrivez une metamorphic relation par fonction de test. Nommez-la clairement. Exécutez-la en CI aux côtés de vos tests unitaires. Quand une relation échoue, traitez-la exactement comme n’importe quel autre échec de test.
Voici un exemple légèrement plus réaliste testant une fonction de recherche de chemin :
import math
def shortest_path_cost(graph, start, end):
"""Returns the cost of the shortest path. Assume implemented."""
pass
def test_shortest_path_undirected_symmetry():
"""MR: In an undirected graph, path cost A->B equals B->A."""
graph = {
'A': [('B', 3.0), ('C', 1.0)],
'B': [('A', 3.0), ('C', 1.0)],
'C': [('A', 1.0), ('B', 1.0)],
}
ab = shortest_path_cost(graph, 'A', 'B')
ba = shortest_path_cost(graph, 'B', 'A')
assert math.isclose(ab, ba, rel_tol=1e-9), f"Symmetry failed: {ab} != {ba}"
def test_shortest_path_subpath():
"""MR: The shortest path cost cannot exceed any specific path's cost."""
graph = {
'A': [('B', 2.0), ('C', 10.0)],
'B': [('C', 2.0)],
'C': [],
}
cost = shortest_path_cost(graph, 'A', 'C')
assert cost <= 10.0, f"Subpath MR failed: {cost} > 10.0"
assert math.isclose(cost, 4.0, rel_tol=1e-9), f"Expected 4.0, got {cost}"
Vous ne testez pas l’algorithme lui-même. Vous testez votre implémentation de celui-ci.
FAQ
Le metamorphic testing remplace-t-il les tests unitaires ?
Non. Il les complète. Utilisez les tests unitaires quand vous connaissez la sortie attendue. Utilisez les tests metamorphic quand vous ne la connaissez pas.
Puis-je utiliser cela pour des modèles ML ?
Oui, et c’est l’un des domaines de recherche les plus actifs. Des relations comme « faire pivoter l’image d’un chat devrait toujours le classifier comme un chat » sont des metamorphic relations. Des chercheurs ont trouvé des problèmes de fiabilité de modèles et des écarts d’équité en utilisant cette approche.
Comment savoir si ma metamorphic relation est correcte ?
Vous ne la prouvez pas. Vous argumentez à partir de la spécification ou des propriétés mathématiques du domaine. Si votre relation elle-même est buggée, vous obtiendrez des faux positifs. Commencez par des propriétés évidentes et ajoutez-en au fur et à mesure que vous gagnez en confiance.
Et les tests flaky ?
Les systèmes non déterministes (algorithmes probabilistes, code concurrent, systèmes avec des timeouts) rendent le metamorphic testing plus difficile. Vous devrez peut-être exécuter plusieurs essais ou utiliser des relations statistiques plutôt qu’une égalité exacte.
Commencez par une relation
Vous n’avez pas besoin d’un doctorat pour utiliser cela. Choisissez une fonction dans votre système où vous sautez actuellement les tests parce que vérifier la sortie est trop difficile. Écrivez une metamorphic relation. Exécutez-la. Si vous voulez aller plus loin, l’outil gfauto de l’équipe GraphicsFuzz est open source, et l’étude de synthèse de l’ACM par Segura et al. recense des relations dans des dizaines de domaines.
La technique a trouvé 147 bugs de compilateur, confirmé des défauts dans des plateformes de simulation automobile, et exposé une classe de défaillances de perception dans des voitures autonomes avant qu’elles n’atteignent la route. Les bugs sont réels. La seule question est de savoir si vous les cherchez.