Quand un test est pénible à écrire, c’est souvent le code qu’il faut revoir

Quatre façons de contourner l'interface d'un module dans un test, et ce que chacune révèle sur le découpage du code.


Récemment, dans un projet Symfony pour l’agence où je travaille, j’ai passé une bonne demi-heure sur un test qui refusait de se laisser écrire. La méthode que je voulais couvrir était privée, et j’ai fini par sortir la réflexion PHP pour y accéder. Le test est passé au vert, et je suis passé à autre chose.

Sauf que j’y suis revenu le lendemain. En fait, cette gêne au moment d’écrire le test disait quelque chose sur le découpage de mon code.

Un test est un appelant comme un autre

Un test et un contrôleur font structurellement la même chose. Les deux instancient le module, l’appellent avec des données, regardent ce qui sort. Ils passent par la même porte d’entrée.

Du coup, ce que le test a besoin de savoir, les autres appelants en ont besoin aussi. Si le test réclame plus que ce que l’interface donne, c’est que l’interface est trop étroite.

C’est ça qui rend le principe utilisable au quotidien. Si je pense « ce test est pénible à écrire », c’est qu’il y a quelque chose à chercher dans l’interface du code.

Quatre façons de contourner l’interface

Voici les quatre contournements que je rencontre le plus souvent.

1. La réflexion sur une méthode privée

$m = new \ReflectionMethod(PriceCalculator::class, 'applyTierRules');
$m->setAccessible(true);
$result = $m->invoke($calculator, $cart, $tier);

Ce qu’il faut voir ici, c’est l’envie elle-même. On ne ressent jamais le besoin de tester une privée qui est un vrai détail d’implémentation, donc si celle-là attire un test, c’est qu’elle porte une logique autonome avec sa valeur propre.

Le geste correct : extraire un TierRuleEngine avec sa propre interface. PriceCalculator devient alors un appelant de plus.

2. Le mock d’un collaborateur interne

$mailer = $this->createMock(MailerInterface::class);
$mailer->expects($this->once())
    ->method('send')
    ->with($this->callback(fn(Email $e) =>
        $e->getSubject() === 'Commande #42 confirmée'
        && str_contains($e->getHtmlBody(), '149,90 €')
    ));

$notifier->notifyOrderConfirmed($order);

Le point où il faut prêter attention, c’est ce sur quoi porte l’assertion : le mail qui part au milieu de la méthode. Le test se retrouve donc couplé à la présence d’un MailerInterface dans l’implémentation. Remplacez le mailer par un bus de messages, le comportement reste identique et le test casse.

Ici, la construction du mail mérite sa propre classe.

final class OrderConfirmationMessage
{
    public function build(Order $o): Email { /* … */ }
}

Le test de contenu passe maintenant par cette interface. Il reste un test du notifier, avec un seul mock et une seule assertion : ça envoie.

3. L’observation directe en base

$service->archiveExpiredCarts();

$rows = $this->connection->fetchAllAssociative(
    'SELECT id, archived_at FROM cart WHERE archived_at IS NOT NULL'
);
$this->assertCount(3, $rows);

Si on regarde la deuxième instruction, le test va chercher l’information par-derrière, en SQL, parce que la méthode retourne void. L’interface ne dit rien de ce qui s’est passé.

La correction tient dans la signature :

public function archiveExpiredCarts(): ArchiveReport;

Et là, il se produit quelque chose d’intéressant. Ce dont le test avait besoin, le contrôleur en avait besoin aussi : pour logger, pour afficher un message flash, pour décider d’un nouvel essai.

4. La méthode ajoutée pour les tests

/** @internal Utilisé uniquement par les tests */
public function getPendingOperations(): array { return $this->pending; }

Cette méthode agrandit l’interface publique sans servir un seul appelant réel.

Deux lectures possibles. Soit l’état exposé appartient vraiment au domaine, et on retombe sur le cas 3. Soit le module accumule et exécute en même temps, et il faut alors séparer les deux responsabilités.

Le piège de la testabilité

Attention à la fausse bonne idée : passer des méthodes en public « pour la testabilité ». Chaque méthode passée en public agrandit l’interface du module.

Le module devient testable en devenant superficiel. On perd donc exactement ce qui faisait son intérêt.

La testabilité s’obtient en déplaçant la frontière.

Les frontières internes restent légitimes

Il ne faut pas sur-appliquer le principe pour autant. Un module profond peut très bien se composer de petites pièces injectables, testées séparément, chacune à travers sa propre interface.

La différence est subtile. Une frontière interne, c’est un test qui appelle un sous-module par l’interface de ce sous-module, donc il s’agit simplement d’un module plus petit. Le symptôme apparaît quand le test appelle le module extérieur, puis va regarder ailleurs pour savoir ce qui s’est passé.

Bref, décomposer reste sain. Le signal d’alerte dont il faut être conscient, c’est quand on observe par un autre chemin que l’interface.

Les trois gestes de réparation

Face à l’envie de contourner, un seul de ces trois gestes est le bon :

  • Extraire : ce qu’on veut tester est un module déguisé, il lui faut sa propre interface (cas 1 et 2).
  • Retourner au lieu de muter : l’observabilité manquante appartient au domaine, elle a sa place dans la valeur de retour (cas 3).
  • Déplacer la frontière : le module a la bonne matière avec le mauvais périmètre.

Restent trois réflexes à éviter : la réflexion, le getter @internal, l’assertion SQL.

Et quand on travaille avec un agent

Ce point vaut le détour si vous faites travailler Claude Code ou un autre agent sur votre base. Des tests qui passent uniquement par les interfaces restent valables même après avoir réécrit l’intérieur du code.

Vous pouvez demander à l’agent de réécrire l’intérieur d’un module en gardant la suite au vert, et le vert veut alors dire quelque chose. Dès que les tests attrapent les internes, l’agent voit quarante tests rouges sans pouvoir distinguer un comportement cassé d’un collaborateur déplacé. Il finit par réparer les tests pour les faire passer, et au final le vert redevient du bruit.

Je ne sais pas si cette grille couvre tous les cas, et je ne suis pas encore un pro des tests. Mais dorénavant je me pose la question devant un test pénible, et je regarde ce qu’il raconte sur le système qu’on essaie de tester.

Et vous, avez-vous déjà revu le découpage d’une classe à cause d’un test pénible à écrire ?


À lire aussi

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée.