« On ne touche plus à ce fichier, personne ne sait exactement ce qu’il fait. » Cette phrase, prononcée lors d’un audit chez un client qui gérait une extension de tarification complexe pour un site B2B, résume bien le problème : une fonction de 400 lignes, aucun test, des règles de calcul accumulées au fil de six années de demandes clients successives, et une peur collective de la modifier de peur de casser une règle métier invisible. Ajouter un test qui vérifie « ce que la fonction devrait faire » est impossible : personne ne sait plus vraiment ce qu’elle devrait faire, seulement ce qu’elle fait.
C’est exactement la situation où les tests de caractérisation trouvent leur utilité. Contrairement à un test unitaire classique, qui vérifie qu’un comportement attendu est respecté, un test de caractérisation se contente de figer le comportement observé, bug éventuel compris, pour donner un filet de sécurité avant toute intervention.
La différence essentielle avec un test unitaire classique
Un test unitaire traditionnel part d’une spécification : on sait ce que la fonction doit faire, on écrit le test avant ou en même temps que le code, et un échec signale une régression par rapport à cette spécification. Un test de caractérisation part de l’autre bout : on ignore ou on n’est plus certain de la spécification, on observe ce que le code fait réellement aujourd’hui pour un jeu d’entrées donné, et on fige ce résultat comme référence.
Cela signifie assumer, temporairement, de figer aussi des comportements qui sont en réalité des bugs. Ce n’est pas gênant : l’objectif immédiat n’est pas de corriger le code, mais de pouvoir le modifier ensuite sans provoquer de régression non détectée. Une fois le filet de sécurité en place, corriger un bug identifié devient un choix conscient, avec un test mis à jour en conséquence, plutôt qu’un accident de refactorisation.
Étape 1 : cartographier les entrées et sorties observables
Avant d’écrire le moindre test, on liste les points d’entrée réels de la fonction ciblée : quels paramètres, quelles options globales, quel état de base de données influencent son résultat. Sur la fonction de tarification citée en introduction, cette cartographie a révélé qu’elle lisait silencieusement trois options WordPress différentes en plus de ses paramètres explicites, un couplage caché qu’aucun développeur n’avait mentionné spontanément.

Étape 2 : générer des cas représentatifs, pas exhaustifs
On ne cherche pas à couvrir toutes les combinaisons possibles, ce qui serait souvent combinatoirement impossible, mais à couvrir les cas réellement rencontrés en production : les valeurs limites connues, les configurations client les plus courantes, et quelques cas extrêmes identifiés dans les tickets de support archivés. Pour la fonction de tarification, l’équipe a extrait de la base de production une trentaine de commandes réelles anonymisées, représentatives des combinaisons de remises, taxes et frais de port effectivement rencontrées.
public function test_caracterisation_tarif_cas_reel_1() {
$commande = $this->charger_fixture( 'commande-anonymisee-01.json' );
$resultat = calculer_tarif_final( $commande );
// Valeur figée telle qu'observée, pas déduite d'une règle métier documentée
$this->assertSame( 84.32, $resultat['total_ttc'] );
$this->assertSame( 3, $resultat['nombre_lignes_remise'] );
}
Le commentaire dans le test est volontaire : il rappelle que la valeur attendue n’a pas été déduite d’une spécification, mais relevée sur le comportement observé, une nuance importante pour quiconque relira ce test plus tard sans le contexte de sa création.
Étape 3 : automatiser la génération des assertions
Écrire à la main la valeur attendue pour trente cas serait long et source d’erreurs de recopie. Une technique éprouvée consiste à exécuter d’abord le test avec une assertion volontairement fausse ou absente, à laisser PHPUnit afficher la valeur réellement produite dans son message d’échec, puis à copier cette valeur comme référence figée. Certains outils de test de caractérisation automatisent complètement cette étape via des snapshots, qui stockent la sortie sérialisée dans un fichier séparé plutôt que dans une assertion en dur :
public function test_caracterisation_tarif_snapshot() {
$commande = $this->charger_fixture( 'commande-anonymisee-02.json' );
$resultat = calculer_tarif_final( $commande );
$this->assertMatchesJsonSnapshot( wp_json_encode( $resultat ) );
}
Étape 4 : refactoriser à l’abri du filet
Une fois la suite de tests de caractérisation en place et verte, le code peut être découpé, renommé, simplifié, sans que la fonction change de comportement observable pour les cas couverts. Toute modification qui casse un test de caractérisation doit être examinée précisément : est-ce une régression accidentelle, ou une correction volontaire d’un bug désormais identifié ? Dans ce second cas, on met à jour le test en connaissance de cause, jamais en le contournant silencieusement.
Un test de caractérisation qui capture un bug n’est pas un aveu d’échec : c’est la première fois que ce bug devient visible et documenté, plutôt que caché dans du code que personne n’ose lire en entier.
Les limites à ne pas perdre de vue
Ces tests protègent contre les régressions de comportement, ils ne garantissent en rien la qualité de conception du code sous-jacent, ni sa performance, ni sa sécurité. Ils ne remplacent pas non plus un audit fonctionnel avec les parties prenantes métier, seules capables de dire, au final, quels comportements observés sont réellement des règles voulues et lesquels sont des anomalies à corriger.
En résumé
Face à un code hérité sans specification claire ni test existant, tenter d’écrire directement des tests unitaires « corrects » revient souvent à deviner une intention perdue. Les tests de caractérisation offrent une voie plus honnête : capturer d’abord ce qui existe réellement, aussi imparfait soit-il, pour ensuite modifier le code en toute confiance. C’est un préalable, pas une fin en soi : le vrai travail de nettoyage commence une fois ce filet de sécurité posé.