Trop de tests : quand le filet de sécurité devient une camisole

Vous avez déjà voulu renommer une méthode, extraire une classe, changer l'ordre de deux appels, et vu quarante tests passer au rouge alors que le comportement n'a pas bougé d'un iota ? Vous relisez votre changement, il est correct, l'application marche. Ce sont les tests qui refusent. Vous passez l'après-midi à les réparer un par un, et à la fin vous vous demandez ce que vous avez vraiment sécurisé.

Une suite de tests censée protéger le refactoring finit par l'interdire. On a tellement testé, tellement figé chaque détail, que le code ne peut plus bouger sans casser sa propre suite. Le filet de sécurité s'est transformé en camisole.

Mettons une chose au clair d'entrée, parce que le sujet se prête au malentendu : ce n'est pas un plaidoyer contre les tests. Une base de code sans tests est ingérable, personne ne discute ça. Le problème n'est pas de tester, c'est de trop tester, et surtout de mal tester : figer le comment au lieu de vérifier le résultat.


Le test qui teste le comment

Regardons un cas réel, côté logiciel hospitalier. On développe en TDD la validation d'une prescription, en partant d'un comportement : une prescription en intraveineuse à dose élevée ne peut pas être validée sans l'aval d'un pharmacien. On écrit le test, on le fait passer, la règle tient d'abord dans le service.

Puis le service grossit, d'autres règles arrivent, et au moment du refactor on extrait chacune dans sa propre classe. Le réflexe TDD « une classe, un test » nous fait alors écrire un test unitaire collé à la classe fraîchement extraite :

@Test
void la_regle_intraveineuse_exige_le_pharmacien() {
    var regle = new RegleVigilanceIntraveineuse();
    var prescription = new Prescription(doseElevee, Voie.INTRAVEINEUSE);

    var statut = regle.appliquer(prescription);

    assertThat(statut).isEqualTo(Statut.EN_ATTENTE_PHARMACIEN);
}

Le test a l'air sain : une entrée, une assertion métier claire, pas un seul mock. Et pourtant il ne teste pas vraiment le comportement « intraveineuse à dose élevée exige le pharmacien ». Il teste qu'il existe une classe RegleVigilanceIntraveineuse, dotée d'une méthode appliquer qui prend une Prescription et rend un Statut. Il a photographié le découpage du jour.

Le jour où vous fusionnez ces règles éparses dans un moteur unique, ou vous les basculez dans une table de configuration, le comportement observable ne bouge pas : une intraveineuse à dose élevée réclame toujours le pharmacien. Mais ce test explose, avec les dix autres coulés dans le même moule. Ils ne testaient pas ce que le code fait, ils testaient comment il était découpé ce jour-là. Le couplage prend parfois une autre forme, un mock qui vérifie l'ordre exact des appels internes en est une, mais le piège reste le même : le test épouse la structure au lieu du résultat.

Un test couplé à l'implémentation transforme chaque refactoring en chantier. Vous ne touchez pas au comportement, et pourtant vous devez réécrire le test. Pire : à force de le réécrire pour qu'il repasse au vert, vous finissez par le recalquer sur le nouveau code. À ce stade le test ne vérifie plus rien d'indépendant, il paraphrase l'implémentation qu'il est censé surveiller.

Vladimir Khorikov, dans Unit Testing Principles, Practices, and Patterns, en fait un des quatre critères d'un bon test : la résistance au refactoring. Un test qui vire au rouge alors que le comportement n'a pas changé est un faux positif, et les faux positifs à répétition finissent par apprendre à l'équipe à ne plus croire ses tests. Un test qui crie au loup à chaque déplacement de code perd sa seule vraie valeur : dire quelque chose de fiable quand il passe au rouge.


Le prix qu'on paie à trop tester

Le premier coût est direct : le temps passé à réparer des tests qui n'auraient jamais dû casser. Multiplié par chaque refactoring, chaque renommage, chaque déplacement de responsabilité, ça devient une taxe permanente sur toute évolution du code.

Le deuxième coût est plus vicieux : la peur de refactorer. Quand on sait qu'un petit changement va virer trente tests au rouge, on ne le fait pas. On laisse la méthode de 200 lignes telle quelle. On empile un if de plus au lieu d'extraire proprement. La suite de tests, censée autoriser le changement, devient la raison pour laquelle on ne change plus rien. Le sur-testing produit alors exactement le contraire de son intention : un code que personne n'ose plus améliorer.

Le troisième coût est le plus trompeur : la fausse confiance. Une suite de mille tests peut passer intégralement au vert sur un code qui ne tient pas ses promesses, dès lors que ces tests traversent le code sans jamais contrôler ce qu'il produit. Beaucoup de tests, beaucoup de vert, peu de garanties.

Une objection se pose ici, et elle est solide : si trente tests cassent au moindre refactoring, peut-être que le fautif n'est pas la suite, mais le code qu'elle teste. Un découpage qui laisse fuiter sa structure jusque dans les tests, c'est un signal. Gerard Meszaros a nommé ce cas dans xUnit Test Patterns : un test fragile est parfois le symptôme d'un logiciel sur-spécifié, trop couplé, pas seulement d'un mauvais test. C'est vrai. Mais ça ne disculpe pas le sur-testing : quand chaque classe traîne son test collé, on multiplie les points de contact avec cette structure fuyante, donc on multiplie la casse. Le mauvais design est la cause, le sur-testing en est l'amplificateur.


Le coverage ne prouve pas grand-chose

L'indicateur qu'on brandit pour justifier ce maillage de tests, c'est le taux de couverture. « On est à 90 % de coverage. » La phrase rassure, elle ne veut pas dire ce qu'on croit.

Le coverage mesure une seule chose : quelles lignes ont été exécutées pendant les tests. Il ne dit rien de ce qui a été vérifié. Une ligne peut être traversée par dix tests sans qu'aucune assertion ne contrôle son effet.

La démonstration tient en un exemple. Voici une règle métier et un test qui la « couvre » à 100 % :

public boolean necessiteValidationPharmacien(Prescription p) {
    return p.getDoseParPrise() > seuilVigilance
        && p.getVoie() == Voie.INTRAVEINEUSE;
}
@Test
void couvre_la_methode() {
    Prescription p = new Prescription(doseElevee, Voie.INTRAVEINEUSE);
    service.necessiteValidationPharmacien(p); // aucun assert
}

Cent pour cent de la méthode est couverte. La ligne est exécutée, elle apparaît en vert dans le rapport. Et pourtant ce test ne vérifie rien du tout : pas d'assertion. On pourrait remplacer le && par un ||, inverser le > en <, retourner false en dur, le test resterait vert et la couverture de lignes à 100 %. L'indicateur récompense le fait d'avoir traversé le code, pas celui de l'avoir vérifié. Une couverture de branches serait plus sévère, ce cas unique n'exerçant jamais les chemins où les conditions sont fausses ; mais c'est bien le chiffre des lignes qu'on brandit en réunion.

Un taux de couverture élevé est une condition nécessaire, jamais suffisante. Il détecte le code jamais testé, ce qui est utile. Il ne dit strictement rien de la qualité des tests qui couvrent le reste.


Ce que le mutation testing révèle

Si le coverage ne mesure pas la qualité des tests, qu'est-ce qui la mesure ? Une réponse existe, et elle est bien plus honnête : le mutation testing.

Le principe est retors et brillant. Un outil de mutation (PIT / Pitest dans le monde Java) modifie automatiquement votre code de production : il décale une borne (> devient >=), il inverse une condition (== devient !=), il remplace un + par un -, il supprime un appel de méthode. Chaque modification est un mutant. Puis il rejoue vos tests contre chaque mutant.

Le raisonnement est simple : si vous avez introduit un bug dans le code et qu'aucun test ne passe au rouge, c'est que vos tests ne vérifiaient pas vraiment ce bout de logique. Le mutant a survécu. À l'inverse, un mutant tué par un test rouge prouve qu'au moins un test contrôle réellement ce comportement.

Reprenons la règle de validation pharmacien. Le coverage disait 100 %. Le mutation testing, lui, décale la borne du seuil (> devient >=), rejoue le test qui couvre la ligne, et le test reste vert. Mutant survivant. Verdict immédiat : ce test ne protège rien. Là où le coverage affichait un beau chiffre rassurant, la mutation met le doigt sur le trou réel.

Ce que le mutation testing change, dans la pratique :

Ce n'est pas magique pour autant. Le mutation testing est lent (pour chaque mutant, il rejoue les tests qui couvrent la ligne mutée, multiplié par le nombre de mutants) et bruyant (certains mutants sont équivalents au code d'origine et impossibles à tuer). On ne le lance pas à chaque commit sur toute la base. On le réserve au code qui porte de la vraie logique métier, en local ou dans un job de CI dédié. C'est un scalpel, à sortir sur les zones qui le méritent.


L'échafaudage du TDD qu'on oublie de démonter

Ce maillage de tests classe par classe a souvent une origine précise : un TDD mal digéré.

En TDD, on écrit un test avant chaque bout de code, par tout petits pas, pour faire émerger le design. Ces tests sont d'abord un outil de conception : ils pilotent l'écriture, ils cadrent les responsabilités, ils font apparaître les classes une à une. C'est leur rôle, et c'est un bon rôle.

Le problème vient de la troisième étape du cycle, le refactor du red-green-refactor. On l'applique religieusement au code de production, presque jamais aux tests. Une fois le design stabilisé, on garde l'intégralité des micro-tests qui ont servi à y arriver, y compris ceux soudés à une classe intermédiaire, à un découpage provisoire, à une étape franchie depuis longtemps. L'échafaudage a fait son travail, le bâtiment tient debout, et on le laisse accroché à la façade.

Or les tests qui pilotent le design et les tests qui protègent le comportement ne sont pas les mêmes. Les premiers sont jetables : ils ont servi à penser, ils peuvent partir une fois la pensée posée. Les seconds sont à garder : ils disent ce que le système promet. Garder tout, c'est confondre l'outil de conception avec le filet de sécurité, et se retrouver avec un test par classe dont chaque maille est collée à une décision d'implémentation qu'on n'a plus le droit de rejouer.

Faire du TDD ne condamne donc pas au sur-testing, au contraire. Mais ça réclame une discipline qu'on saute presque toujours : à la fin du cycle, se demander lesquels de ces tests protègent vraiment un comportement, et supprimer les autres.

Le mutation testing donne justement de quoi trancher sans naviguer à l'aveugle. Si deux tests tuent exactement les mêmes mutants, ils apportent la même garantie : l'un peut partir sans qu'on perde quoi que ce soit. L'outil qui servait à débusquer les trous sert alors à repérer les doublons, et à démonter l'échafaudage sur des faits plutôt que sur une impression.


Tester le résultat à la frontière, pas chaque classe du domaine

Le vrai changement de posture est là. Ce maillage classe par classe, chaque classe isolée de ses voisines par ses propres mocks, c'est lui qui soude les tests à l'implémentation.

L'alternative : tester le domaine par sa frontière. On entre par le cas d'usage, le service applicatif qui sert de porte d'entrée au domaine, on fournit un vrai contexte, et on vérifie le résultat observable en sortie. Ce qu'il y a entre les deux, les classes internes, la façon dont les responsabilités sont découpées, tout ça devient libre.

C'est la thèse qu'Ian Cooper défend dans son talk « TDD, Where Did It All Go Wrong » : ce qui déclenche un test, c'est un comportement à couvrir, pas une classe qu'on vient d'écrire ; l'unité qu'on teste, c'est le module et son API, pas chaque classe qu'il contient.

@Test
void une_prescription_en_intraveineuse_a_dose_elevee_exige_le_pharmacien() {
    var pharmacie = new PharmacieRepositoryFake();
    var validation = new ValidationPrescription(pharmacie); // le point d'entrée du domaine

    var resultat = validation.valider(
        prescription().voie(INTRAVEINEUSE).dose(doseElevee).build());

    assertThat(resultat.statut()).isEqualTo(EN_ATTENTE_PHARMACIEN);
}

Ce test ne connaît ni le Validator, ni le Calculator, ni l'ordre des appels. Il connaît une entrée métier et un résultat métier. Vous pouvez extraire une classe, en fusionner deux, renommer la moitié du domaine, réorganiser complètement l'intérieur : tant que la règle « intraveineuse à dose élevée exige le pharmacien » tient, le test reste vert. Il vérifie ce que le domaine promet, sans rien savoir de sa plomberie interne, et c'est exactement ce qui rend le refactoring possible.

On y gagne au passage un Fake, une implémentation réelle mais en mémoire du dépôt, plutôt qu'un stub Mockito pour la persistance. On supprime ainsi le pré-câblage de réponses et le couplage aux signatures internes. La logique traverse du vrai code de bout en bout, du point d'entrée jusqu'au Fake.

Cette frontière reste celle du domaine, pas celle du système : aucune base réelle, aucun serveur HTTP ne monte, le test garde la vitesse d'un unitaire. Entrer par la frontière ne veut pas dire tout basculer en tests end-to-end lents. On déplace le point d'entrée du test, son coût d'exécution ne bouge pas.

Ce n'est pas un blanc-seing pour ne tester que par la frontière. Un algorithme complexe et autonome, riche en cas limites, mérite ses propres tests unitaires rapprochés : les faire passer par le port entier serait lourd et diluerait le diagnostic. La règle que je me donne : tester à la maille la plus haute qui garde des échecs précis. La frontière par défaut, et un test unitaire plus ciblé dès que la logique est assez riche et isolée pour le justifier.


Alors on teste quoi, au juste

Si tout tester est un piège, la question devient : qu'est-ce qui mérite un test ? Le tri se fait sur une seule idée, le risque. Un test coûte à l'écriture et à la maintenance ; il ne se justifie que s'il protège quelque chose qui peut casser et qui fait mal quand ça casse.

Ce qui mérite d'être testé, densément :

Ce qui ne mérite pas qu'on s'acharne :

Le fil rouge : on teste des comportements qui ont de la valeur et du risque, pas des lignes pour faire monter un chiffre.


Là où tester plus se défend

« Testez le résultat, pas le comment » vaut pour l'essentiel du code. Reste les cas où il ne tient pas, et ils existent.

Parfois, le comment est le comportement. Un service dont le seul rôle est de publier un événement sur un bus ou d'appeler une API tierce n'a pas d'état observable en sortie. La seule chose à vérifier, c'est justement l'interaction : l'événement a-t-il été publié, avec la bonne charge utile ? Là, le verify() Mockito vérifie le seul comportement observable qui existe. Rien de sur-testé là-dedans.

De même, sur une frontière sensible, on teste parfois plus finement qu'ailleurs : un contrat d'API publique, un format d'événement consommé par d'autres équipes, une sérialisation qui doit rester stable. Là, figer un détail est le but, pas un accident, parce que ce détail est précisément le contrat.

Et dans un domaine à fort enjeu, la redondance se défend. Un calcul qui touche à la sécurité du patient peut mériter à la fois ses tests unitaires rapprochés et un test par la frontière. Personne n'appellera ça du sur-testing : c'est de la ceinture et des bretelles là où l'erreur ne pardonne pas.

Rien de tout ça n'affaiblit le principe, ça en dessine les bords. Le sur-testing est une faute quand il fige l'implémentation d'un code qu'on veut faire évoluer librement. Il devient légitime quand le détail qu'on verrouille est le comportement lui-même, ou le contrat qu'on doit à d'autres équipes. Dans ces cas-là, verrouiller est précisément ce qu'on attend du test.


Conclusion

Le sur-testing ne vient pas d'un manque de sérieux, il vient d'un excès de zèle mal orienté : on confond quantité de tests et qualité de la protection, et on finit par bétonner le code au lieu de le sécuriser.

Quelques repères pour s'en sortir :

  1. Tester le résultat, pas le comment. Quand un test casse alors que le comportement n'a pas bougé, c'est le test qu'il faut revoir : il s'est accroché à l'implémentation.
  2. Se méfier du coverage seul. Il dit quelles lignes sont exécutées et reste muet sur ce qu'elles produisent. Le mutation testing, lui, mesure si vos tests attraperaient un vrai bug.
  3. Entrer par la frontière. Tester le domaine par sa frontière laisse l'intérieur libre de bouger, et c'est ça qui autorise le refactoring.
  4. Trier par le risque. Concentrez les tests sur la logique critique, effleurez le code sans logique. Quant au framework, ce n'est pas à vos tests de prouver qu'il marche.

Un bon test se juge à une chose : est-ce qu'il vous laisse changer le code en dormant tranquille. La suite de tests est là pour donner le courage de refactorer, pas pour l'enlever. Le jour où vos tests vous empêchent de bouger, ce ne sont plus des tests, ce sont des chaînes.