Comment j'ai mis un filet de tests autour d'une classe de 24 000 lignes


Le mur

Tu viens d’arriver sur le projet. On te pointe un bug : “les remboursements partiels ne fonctionnent pas dans tel cas”. Tu ouvres le fichier. Le mur de verre : 24 000 lignes. Pas un animal en vue : zéro test.

Tu cherches la méthode ProcessRefund. Elle existe. Elle est là, quelque part, enterrée entre 800 lignes de gestion de connexion SMTP et un helper de formatage de date fait maison.

Tu l’ouvres. 120 lignes. Elle fait :

  1. Valider le montant
  2. Chercher le paiement original en base (via un appel static à DatabasePool.GetInstance().Query(...))
  3. Calculer les frais (avec une méthode privée ComputeFees)
  4. Envoyer un email de notification
  5. Journaliser dans un fichier (un StreamWriter static ouvert en début de classe)
  6. Appeler l’API bancaire (via HttpClient instancié sur place)
  7. Mettre à jour le statut de la commande
  8. Retourner un bool

Pas un test. Pas une interface. Pas une injection de dépendances. Juste 24 000 lignes de code procedural qui tiennent debout par la grâce du compilateur.

Tu dois changer ce code. Et tu ne dois rien casser.

Le problème : les tests sont un prérequis au changement

C’est un classique du legacy : pour refactorer sereinement, il faudrait des tests. Mais pour écrire des tests, il faudrait comprendre tout le comportement, et peut-être même refactorer pour exposer des points d’entrée. C’est l’œuf ou la poule.

Il existe une technique qui casse ce cycle : le Golden Master (aussi appelé approval testing ou snapshot testing).

L’idée vient en partie de Michael Feathers dans Working Effectively with Legacy Code : au lieu de décider ce que le code devrait faire, on capture ce qu’il fait, et on s’en sert de baseline.

Mais avec une twist : on ne capture pas juste la valeur de retour. On capture tout ce que fait la méthode. Les appels à la base. Les emails envoyés. Les logs écrits. Les appels API. Tout.

On sauvegarde ce résultat dans un fichier texte, le golden master. Ensuite, chaque modification du code est validée par une comparaison : si la sortie a changé, le test échoue. Soit tu as cassé quelque chose, soit tu as changé intentionnellement le comportement, et tu mets à jour le master.

C’est un contrat de régression qui s’écrit tout seul.

La technique en 4 étapes

1. Rendre les dépendances capturables

Pour capturer tout ce que fait ProcessRefund, on a besoin de seams, des points de couture où intercepter les effets de bord. Même technique qu’avant, mais l’objectif change : au lieu de stubber les dépendances (les réduire à du no-op), on va les transformer en enregistreurs :

// État initial : appel static
public class LegacyPaymentService
{
    public bool ProcessRefund(int orderId, decimal amount)
    {
        var db = DatabasePool.GetInstance();
        var payment = db.Query($"SELECT * FROM Payments WHERE OrderId = {orderId}");
        var result = CallBankApi(payment, amount);
        Log($"Refund processed for order {orderId}");
        return result;
    }
}

On introduit des seams, mais cette fois pour rediriger les sorties :

public class LegacyPaymentService
{
    public bool ProcessRefund(int orderId, decimal amount)
    {
        GetPayment(orderId);
        // ...

        // On enregistre la sortie dans des propriétés qu'on pourra inspecter
    }

    protected virtual DatabaseRow? QueryDatabase(string sql)
    {
        return DatabasePool.GetInstance().Query(sql);
    }

    protected virtual void SendEmail(string to, string body)
    {
        new SmtpClient("smtp.prod.internal").Send(to, body);
    }

    protected virtual void Log(string message)
    {
        File.AppendAllText("/var/log/app.log", message);
    }

    protected virtual bool CallBankApi(decimal amount)
    {
        return new HttpClient().PostAsync(...).Result;
    }
}

Chaque seam a la même signature que l’appel original. Le build passe. Le comportement est inchangé.

Mais dans le test, on va les surcharger pour capturer ce qu’elles reçoivent, pas pour les annuler :

public class CapturingPaymentService : LegacyPaymentService
{
    public List<string> CapturedCalls { get; } = new();

    protected override DatabaseRow? QueryDatabase(string sql)
    {
        CapturedCalls.Add($"QueryDatabase: {sql}");
        return new DatabaseRow(); // on contrôle ce qu'on retourne
    }

    protected override void SendEmail(string to, string body)
    {
        CapturedCalls.Add($"SendEmail: to={to}, body={body}");
    }

    protected override void Log(string message)
    {
        CapturedCalls.Add($"Log: {message}");
    }

    protected override bool CallBankApi(decimal amount)
    {
        CapturedCalls.Add($"CallBankApi: amount={amount}");
        return true;
    }
}

Chaque appel est enregistré avec ses paramètres. Rien ne va dans la base, rien ne part en email, rien n’est écrit sur le disque. Tout atterrit dans une liste qu’on va transformer en texte.

2. Générer le golden master

Maintenant qu’on capte chaque interaction, on appelle la méthode et on sérialise tout ce qu’on a capturé en une chaîne :

public class LegacyPaymentServiceTests
{
    [Fact]
    public void ProcessRefund_ShouldMatchGoldenMaster()
    {
        var service = new CapturingPaymentService
        {
            DatabaseResult = new DatabaseRow
            {
                ["OrderId"] = "42",
                ["Amount"] = "100.00",
                ["Status"] = "paid"
            }
        };

        service.ProcessRefund(42, 50m);

        // On sérialise tout le comportement observé en une chaîne
        var actual = string.Join("\n", service.CapturedCalls)
                   + $"\nReturnValue: {service.ReturnValue}";

        // Comparer avec le golden master
        // (Première exécution : écrire le fichier, puis comparer)
        ApprovalTests.Approvals.Verify(actual);
    }
}

La première exécution ne compare rien, elle crée le fichier golden master :

QueryDatabase: SELECT * FROM Payments WHERE OrderId = 42
ComputeFees: paymentAmount=100, refundAmount=50
SendEmail: to=customer@test.com, body=Votre remboursement de 50€ est en cours.
Log: Refund processed for order 42
CallBankApi: amount=50
ReturnValue: True

Ce fichier devient la vérité de référence pour le comportement actuel du code. Pas ce que tu penses qu’il devrait faire. Pas ce que le métier aimerait. Ce que le code fait, là, maintenant, avec ce jeu de données.

Et c’est très puissant parce que ça capture TOUT. Si le code a un bug (par exemple, il envoie l’email avant d’appeler l’API bancaire, et l’API échoue), le golden master le capture tel quel. Tu ne décides pas ce qui est “bien” ou “mal”. Tu enregistres l’existant. La correction viendra plus tard, une fois le filet posé.

3. Refactorer sous le filet

Maintenant, tu changes le code. Tu extrais une méthode, tu renommes une variable, tu déplaces une responsabilité.

Tu relances le test.

  • Si la sortie est identique : aucune régression. Le comportement observable n’a pas changé.
  • Si la sortie diffère : le test te montre exactement ce qui a changé. Le diff est lisible :
--- a/golden-master.txt
+++ b/golden-master.txt
 QueryDatabase: SELECT * FROM Payments WHERE OrderId = 42
 ComputeFees: paymentAmount=100, refundAmount=50
-SendEmail: to=customer@test.com, body=Votre remboursement par chèque est en cours.
+SendEmail: to=customer@test.com, body=Votre remboursement de 50€ est en cours.
 Log: Refund processed for order 42
 CallBankApi: amount=50
 ReturnValue: True

Tu vois exactement quel appel a changé, et comment. Soit c’est un bug que tu viens d’introduire, soit c’est le changement souhaité, et tu approuves le nouveau master.

C’est radicalement différent des assertions classiques. Avec une assertion, tu décides à l’avance ce qui est important. Tu écris Assert.Equal(expected, result) et tu ne vérifies que ça. Le reste (l’ordre des appels, les logs, les paramètres exacts), tu ne le vérifies pas.

Avec le golden master, tout est vérifié. Si tu changes par erreur l’ordre des opérations, le test le détecte. Si tu modifies un message de log, le test le détecte. Si tu supprimes un appel, le test le détecte.

C’est à la fois plus large et moins coûteux à écrire.

4. Approuver le changement

Quand le changement est intentionnel, tu mets à jour le golden master. Avec une lib comme ApprovalTests, c’est un outil de diff qui s’ouvre : tu valides le nouveau résultat et le fichier est remplacé.

# Si tu préfères une approche sans outil interactif :
cp actual-output.txt golden-master.txt
git diff golden-master.txt   # vérifier que le diff est bien ce qu'on attend

Le golden master versionné dans git devient l’historique des décisions de changement. Chaque commit qui modifie le master dit : “à partir de maintenant, le comportement attendu est celui-ci.”

Golden master vs tests d’assertion classiques

Critère Assertions classiques Golden Master
Ce qu’on vérifie La sortie choisie Toutes les sorties
Coût d’écriture Élevé (chaque assertion est manuelle) Faible (génération automatique)
Résistance au refactoring Haute (si on assertait l’implémentation) Haute (on compare le comportement observable)
Détection de régressions inattendues Partielle (uniquement ce qui est asserté) Totale (tout écart est détecté)
Lisibilité du test Bonne (intention explicite) Faible (l’intention est dans le golden master)
Mise à jour Changer les asserts un par un Approuver le nouveau master

Le golden master est moins coûteux à écrire mais plus coûteux à maintenir si le comportement change souvent. C’est un outil pour le legacy, pas pour toutes les situations.

La limite : les tests deviennent aveugles aux bugs existants

C’est le point essentiel et contre-intuitif : le golden master capture le code tel qu’il est, pas tel qu’il devrait être.

Si ProcessRefund a un bug (par exemple, il calcule mal les frais pour les montants décimaux), le golden master capture le mauvais calcul. Le test passe. Il détectera les régressions, mais pas le bug historique.

C’est un choix conscient : on ne mélange pas la sécurisation du legacy (comprendre ce que fait le code, pouvoir le changer sans casser) et la correction des bugs. On sécurise d’abord. On corrige ensuite.

Quand tu auras posé le filet, tu pourras écrire un test spécifique pour le bug : “ProcessRefund avec montant 0.07 doit retourner 0.07 et pas 0.06”. Et là, tu corriges. Et le golden master est mis à jour pour refléter le nouveau comportement.

Le workflow complet

1. Identifier la feature slice ──────────────────────┐

2. Introduire des seams (capture, pas stub) ─────────┤

3. Exécuter et générer le golden master ─────────────┤── Premier cycle

4. Refactorer le code ───────────────────────────────┘

       ├── Le master n'a pas changé ──> Aucune regression

       └── Le master a changé ───────> Analyser le diff

               ├── Changement inattendu ──> Corriger le code

               └── Changement intentionnel ──> Approuver le nouveau master

Chaque cycle sécurise un peu plus le code. Au bout de 5 à 10 golden masters couvrant les principaux chemins d’exécution, tu peux refactorer ProcessRefund de fond en comble sans peur.

En résumé

  • Le Golden Master capture tout le comportement d’une méthode dans un fichier texte
  • On utilise des seams en mode capture : on enregistre chaque appel plutôt que de l’annuler
  • La comparaison détecte toute régression, même sur des effets de bord qu’on n’avait pas anticipés
  • Le diff montre exactement ce qui a changé, ce qui permet de décider : bug ou changement intentionnel ?
  • C’est un outil de sécurisation, pas de correction : on capture d’abord, on corrige ensuite

La prochaine fois que quelqu’un te montre une classe de 24 000 lignes et te dit “il faudrait qu’on refactore”, ne pars pas en courant. Pose un filet. Un golden master à la fois.


Le Golden Master, l’Approval Testing et les stratégies de caractérisation de code legacy sont au programme de ma conférence Le Seigneur du Legacy, disponible en Brown Bag Lunch dans vos locaux.