Vous livrez un modèle d’apprentissage automatique qui étiquette les tickets de support. Votre suite de tests est verte. Chaque test a réussi.

Aucun de ces tests ne vérifie réellement si les étiquettes sont correctes. Vous ne savez pas quelles sont les bonnes étiquettes. Personne ne le sait. La sortie « correcte » est pratiquement impossible à connaître pour des entrées du monde réel, alors vous vous rabattez sur la vérification que la fonction ne plante pas, ou que la forme de la sortie correspond aux attentes. Ce n’est pas du test. C’est de l’espoir.

C’est le oracle problem, et il apparaît partout : compilateurs, simulations, algorithmes d’optimisation, détecteurs de fraude, et tout système où la vérité terrain est coûteuse, subjective, ou impossible à calculer. Quand vous ne pouvez pas définir la sortie attendue, les tests unitaires traditionnels s’effondrent.

Le metamorphic testing offre une issue. Au lieu de demander « cette sortie est-elle correcte ? », vous demandez « des entrées liées produisent-elles des sorties qui satisfont une relation connue ? » Si ce n’est pas le cas, votre code est cassé. Si c’est le cas, vous avez gagné une confiance réelle sans jamais connaître la bonne réponse.

Qu’est-ce que le metamorphic testing ?

Le metamorphic testing a été proposé à la fin des années 1990 par Tsong Yueh Chen et ses collègues en réponse à une réalité frustrante : de nombreux programmes sont véritablement utiles mais pratiquement intraitables avec des oracles traditionnels.

L’idée centrale est simple. Vous identifiez une metamorphic relation : une propriété qui devrait être vraie entre les entrées et les sorties de multiples exécutions de votre programme. Vous exécutez le programme sur une entrée source, transformez cette entrée selon la relation, l’exécutez à nouveau, et vérifiez si les sorties se relient les unes aux autres de la manière dont la propriété le prédit.

Pas de sortie attendue requise. Pas d’étiqueteurs humains. Pas de golden dataset.

Considérez une fonction qui trouve le chemin le plus court dans un graphe pondéré :

from typing import List, Tuple, Optional

def shortest_path(
    edges: List[Tuple[int, int, float]],
    start: int,
    end: int
) -> Optional[float]:
    """Dijkstra's algorithm. Returns path length or None if unreachable."""
    import heapq

    graph = {}
    for u, v, w in edges:
        graph.setdefault(u, []).append((v, w))

    dist = {start: 0.0}
    heap = [(0.0, start)]

    while heap:
        d, u = heapq.heappop(heap)
        if u == end:
            return d
        if d > dist.get(u, float('inf')):
            continue
        for v, w in graph.get(u, []):
            nd = d + w
            if nd < dist.get(v, float('inf')):
                dist[v] = nd
                heapq.heappush(heap, (nd, v))

    return None

Pour un graphe complexe, calculer le chemin le plus court attendu à la main est fastidieux. Mais nous connaissons plusieurs metamorphic relations qui doivent être vraies :

  1. Monotonicité par rapport aux poids des arêtes. Si vous augmentez le poids d’une seule arête, le chemin le plus court ne devrait pas devenir plus court. Il peut rester le même (si cette arête n’était pas sur le chemin optimal) ou devenir plus long.

  2. Homogénéité sous mise à l’échelle. Si vous multipliez chaque poids d’arête par une constante positive, la longueur du chemin le plus court devrait se mettre à l’échelle par la même constante.

  3. Symétrie des chemins sur les graphes non orientés. Si le graphe est non orienté, échanger le départ et l’arrivée devrait donner la même longueur de chemin.

Ce ne sont pas des heuristiques. Ce sont des propriétés mathématiques. Si l’une d’elles échoue, l’implémentation est fausse, point final.

Comment écrire un metamorphic test

Voici ce à quoi ressemble la relation de monotonicité en pratique :

import random

def test_shortest_path_monotonicity():
    # Generate a random connected graph
    nodes = list(range(10))
    edges = []
    for i in range(len(nodes) - 1):
        edges.append((i, i + 1, random.uniform(1.0, 10.0)))
    # Add some random cross edges
    for _ in range(10):
        u, v = random.sample(nodes, 2)
        edges.append((u, v, random.uniform(1.0, 10.0)))

    start, end = 0, 9
    original = shortest_path(edges, start, end)
    assert original is not None

    # Increase the weight of one arbitrary edge
    idx = random.randrange(len(edges))
    u, v, w = edges[idx]
    modified_edges = list(edges)
    modified_edges[idx] = (u, v, w + 5.0)

    modified = shortest_path(modified_edges, start, end)
    assert modified is not None
    assert modified >= original

Ce test ne calcule jamais le chemin le plus court attendu. Il n’en a pas besoin. Il vérifie qu’une propriété structurelle est vraie, ce qui suffit à détecter une variété surprenante de bugs : erreurs de signe, erreurs off-by-one dans l’accumulation des poids, ordonnancement incorrect de la file de priorité, et plus encore.

La relation de mise à l’échelle est encore plus simple à tester :

def test_shortest_path_scaling():
    nodes = list(range(8))
    edges = []
    for i in range(len(nodes) - 1):
        edges.append((i, i + 1, random.uniform(2.0, 5.0)))

    start, end = 0, 7
    original = shortest_path(edges, start, end)

    factor = 3.5
    scaled_edges = [(u, v, w * factor) for u, v, w in edges]
    scaled = shortest_path(scaled_edges, start, end)

    assert abs(scaled - original * factor) < 1e-9

Remarquez la tolérance en virgule flottante. Les metamorphic tests ne sont pas immunisés contre les problèmes de précision numérique, alors écrivez vos assertions avec le même soin que vous utiliseriez dans tout autre test numérique.

Où cette approche aide réellement

Le metamorphic testing brille dans les domaines où les oracles traditionnels sont faibles ou inexistants.

Apprentissage automatique. Vous ne connaissez pas le score de sentiment exact pour une critique de film, mais vous savez qu’ajouter le mot « terrible » ne devrait pas augmenter le sentiment positif. Vous ne connaissez pas la bounding box exacte pour un détecteur d’objets, mais vous savez que retourner l’image horizontalement devrait retourner les coordonnées de la bounding box.

Compilateurs. Vérifier qu’un binaire optimisé produit exactement la même sortie qu’un binaire non optimisé pour chaque programme possible est impossible. Mais vous pouvez vérifier que compiler un programme, puis le compiler à nouveau avec une transformation sans effet (comme renommer une variable), produit des binaires sémantiquement équivalents.

Calcul scientifique. Vous ne connaissez pas la trajectoire exacte d’une particule dans une simulation complexe, mais vous savez qu’inverser le temps devrait inverser la trajectoire. Vous ne connaissez pas l’énergie exacte de l’état fondamental d’une molécule, mais vous savez qu’elle devrait diminuer (ou rester constante) quand vous augmentez la taille de l’ensemble de base.

Dans chaque cas, l’intuition est la même : la correction ne consiste pas toujours à correspondre à une seule valeur attendue. Parfois, il s’agit de préserver la structure à travers les transformations.

Les compromis et les limites

Le metamorphic testing n’est pas gratuit, et ce n’est pas un remplacement pour chaque autre type de test.

Les relations peuvent être incomplètes. Un programme peut passer chaque metamorphic relation que vous définissez et être encore faux. Si votre ensemble de relations ne couvre pas une classe de bugs particulière, ce bug passe à travers. C’est le problème de couverture, et il est réel.

Les relations peuvent être fausses. Si vous affirmez par erreur qu’une propriété est vraie quand elle ne l’est pas, votre test devient une usine à faux positifs. J’ai un jour vu une équipe affirmer que le clustering k-means devrait être invariant à la mise à l’échelle des caractéristiques. Ce n’est pas le cas. Les centroïdes se mettent à l’échelle avec les données. Le test a réussi pendant des mois parce que le facteur de mise à l’échelle se trouvait être 1,0 dans les données de test. Quand des données réelles sont arrivées, le modèle s’est dégradé silencieusement tandis que les tests restaient verts.

Le débogage des échecs est plus difficile. Quand un test unitaire traditionnel échoue, vous savez exactement quelle était la sortie attendue. Quand un metamorphic test échoue, vous savez qu’une relation a été violée, mais vous ne connaissez toujours pas la sortie correcte. Vous devez raisonner à rebours à partir de la violation de propriété jusqu’au bug sous-jacent, ce qui peut représenter plus de travail.

La génération de données de test compte. Des graphes aléatoires, des phrases aléatoires, et des images aléatoires ne sont pas représentatifs des entrées réelles. Un metamorphic test sur des données synthétiques peut réussir tandis que le système de production échoue sur des cas limites que votre générateur n’a jamais produits. Utilisez des bibliothèques de property-based testing comme Hypothesis pour aider, mais restez sceptique quant à vos propres générateurs.

Comment commencer à l’utiliser aujourd’hui

Vous n’avez pas besoin d’un nouveau framework. Vous avez besoin de trois choses :

  1. Choisissez une fonction avec un oracle faible. Une méthode d’inférence de modèle, un calcul géométrique, une étape de simulation. Quelque chose où vous testez actuellement « ça ne plante pas » et où vous souhaiteriez pouvoir faire plus.

  2. Faites un brainstorming de trois relations. Demandez-vous : quelles transformations devraient laisser la sortie inchangée ? Quelles transformations devraient changer la sortie de manière prévisible ? Quelles paires d’entrées devraient produire des sorties liées ? Notez-les, même si elles semblent évidentes.

  3. Implémentez une relation comme test. Exécutez-la sur des entrées aléatoires. Si elle échoue, vous avez trouvé un bug ou une relation fausse. Les deux sont précieux.

Pour l’exemple k-means que j’ai mentionné plus tôt, un ensemble de relations correct ressemble à ceci :

import numpy as np
from sklearn.cluster import KMeans

def test_kmeans_translation_invariance():
    X = np.random.rand(100, 3)
    shift = np.array([10.0, -5.0, 2.0])

    km1 = KMeans(n_clusters=3, random_state=42, n_init=10).fit(X)
    km2 = KMeans(n_clusters=3, random_state=42, n_init=10).fit(X + shift)

    # Centroids should differ by exactly the shift vector
    np.testing.assert_allclose(km1.cluster_centers_ + shift, km2.cluster_centers_)

Ce test échouera si la logique de clustering gère mal le système de coordonnées, et il le fait sans jamais affirmer quels sont les centroïdes corrects.

Quand vous ne pouvez pas connaître la réponse, testez la structure

Le oracle problem n’est pas un cas limite du test. C’est l’état par défaut pour une vaste classe de logiciels utiles. Le metamorphic testing ne le résout pas complètement, mais il vous fait passer de « je ne peux pas tester ça » à « je peux tester les propriétés qui comptent ».

Commencez par une relation, une fonction, et un vrai bug qu’elle attrape. C’est suffisant pour justifier l’approche. Tout le reste consiste simplement à ajouter plus de relations.