Pourquoi les librairies de mock sont (presque toujours) un piège


L’argument séduisant

Les librairies de mock ont une promesse claire : moins de code à écrire. Pas besoin de créer un SpyEmailSender à la main — Substitute.For<IEmailSender>() génère la doublure en une ligne.

C’est vrai. Et c’est à peu près là que s’arrêtent les avantages.

Le piège de Arg.Any<>()

Voici un test typique avec NSubstitute :

[Fact]
public void NotifyUser_SendsEmailWithCorrectBody()
{
    var userRepo = Substitute.For<IUserRepository>();
    userRepo.GetById(1).Returns(new User { Id = 1, Name = "Alice", Email = "alice@test.com" });

    var emailSender = Substitute.For<IEmailSender>();
    var service = new NotificationService(userRepo, emailSender);

    service.NotifyUser(1, "Bienvenue !");

    emailSender.Received(1).Send(
        "alice@test.com",
        Arg.Any<string>(),   // sujet : on ne veut pas le figer
        Arg.Any<string>()    // body : pareil
    );
}

Le test passe. Il vérifie que l’email a bien été envoyé à Alice. On utilise Arg.Any<string>() pour le sujet et le body — pour ne pas coupler le test à des formulations qui peuvent changer.

Raisonnable. Sauf qu’une semaine plus tard, un développeur modifie NotifyUser pour ajouter une formule de politesse :

// Avant
var body = message;

// Après — oups, le message a disparu
var body = $"Bonjour {user.Name},\n\nCordialement,\nL'équipe";

Le message passé en paramètre a été oublié dans le refactoring. C’est une régression classique.

Le test passe toujours. Arg.Any<string>() accepte n’importe quel body, y compris un body qui ne contient pas le message.

L’impossible juste milieu

Le problème de .Received() est structurel : il n’existe pas de bon niveau d’assertion. Le TP que je donne au CNAM le démontre en deux étapes.

Étape A — un refactoring cosmétique

Un collègue change le format du sujet des emails. Avant : "Notification du 15/06/2025". Après : "[15/06/2025] Notification". Le comportement n’a pas changé — c’est une reformulation.

Si les tests NSubstitute vérifient le sujet en argument exact :

emailSender.Received(1).Send(
    "alice@test.com",
    "Notification du 15/06/2025",  // ← casse pour une reformulation cosmétique
    "Bienvenue !"
);

Ils cassent. Pour un changement que personne ne considère comme une régression.

Étape B — une vraie régression

Pour corriger ça, on passe à Arg.Any<>() sur les arguments susceptibles de changer :

emailSender.Received(1).Send(
    "alice@test.com",
    Arg.Any<string>(),
    Arg.Any<string>()  // on ne veut pas figer le body
);

Maintenant, le product owner demande d’ajouter une formule de politesse. Un développeur modifie le body :

// Avant
var body = message;

// Après — le message a disparu sans que personne le remarque
var body = $"Bonjour {user.Name},\n\nCordialement,\nL'équipe";

Le message passé en paramètre est oublié. C’est une vraie régression — les notifications n’arrivent plus avec le contenu attendu.

Le test NSubstitute passe. Arg.Any<string>() accepte n’importe quel body, y compris un body vide du message original.

C’est le piège dans toute sa clarté : pour éviter la fragilité de l’étape A, on utilise Arg.Any<>(). Ce faisant, on crée l’aveuglement de l’étape B. Il n’y a pas de juste milieu facile. Vérifier certains arguments mais pas d’autres (Arg.Is<string>(s => s.Contains("Bienvenue"))) est possible, mais c’est une API complexe, peu lisible, et qui reste fragile aux reformulations.

Ce que le spy fait à la place

Un spy enregistre ce qui a été envoyé. C’est le test qui décide quoi vérifier — avec toute la flexibilité des assertions xUnit habituelles :

public class SpyEmailSender : IEmailSender
{
    public List<(string To, string Subject, string Body)> SentEmails { get; } = new();

    public void Send(string to, string subject, string body)
        => SentEmails.Add((to, subject, body));
}
[Fact]
public void NotifyUser_SendsEmailWithCorrectBody()
{
    var spy = new SpyEmailSender();
    var fakeRepo = new FakeUserRepository();
    fakeRepo.Add(new User { Id = 1, Name = "Alice", Email = "alice@test.com" });

    var service = new NotificationService(fakeRepo, spy);
    service.NotifyUser(1, "Bienvenue !");

    Assert.Single(spy.SentEmails);
    Assert.Equal("alice@test.com", spy.SentEmails[0].To);
    Assert.Contains("Bienvenue !", spy.SentEmails[0].Body); // ← vérifie le contenu, pas le texte exact
}

Ce test gère les deux situations :

  • Étape A (refactoring cosmétique du sujet) : le test ne porte pas sur le sujet, il ne casse pas.
  • Étape B (message perdu dans le body) : Assert.Contains("Bienvenue !", body) échoue immédiatement.

On choisit exactement ce qu’on vérifie, avec la granularité qu’on veut — et cette granularité est celle des assertions xUnit ordinaires, pas d’une API spécialisée à apprendre.

”Mais le spy, ça fait du code à écrire”

C’est le principal argument en faveur des librairies de mock. Mais il ne résiste pas à l’examen.

Avec NSubstitute, on évite d’écrire une classe — mais on écrit quand même du code spécifique dans chaque test. La vraie comparaison, test par test :

// NSubstitute — dans chaque test
var emailSender = Substitute.For<IEmailSender>();
// ... puis à la fin :
emailSender.Received(1).Send("alice@test.com", Arg.Any<string>(), Arg.Any<string>());

// Spy — dans chaque test
var spy = new SpyEmailSender();
// ... puis à la fin :
Assert.Contains("Bienvenue !", spy.SentEmails[0].Body);

Le code par test est comparable. Ce qu’on “économise” avec NSubstitute, c’est uniquement la classe SpyEmailSender — cinq lignes, écrites une fois, réutilisées dans tous les tests. Ce n’est pas une économie, c’est un investissement.

Le gain des librairies de mock est illusoire. Le coût — tests fragiles ou aveugles — est réel.

Quand les librairies de mock sont légitimes

Il existe des cas où Substitute.For<>() est le bon outil :

Les interfaces de bibliothèques tierces. Si vous dépendez de IHttpClientFactory, ILogger<T>, ou toute interface d’un framework externe, écrire un fake à la main peut être complexe. NSubstitute gère ces cas proprement.

Les interfaces avec de nombreuses méthodes dont une seule est utilisée. Un stub généré évite d’implémenter des méthodes qui ne sont pas invoquées dans le test.

La configuration rapide pour un test ponctuel. Pour un test exploratoire, Substitute.For<>() avec .Returns() est pratique — tant qu’on ne glisse pas vers .Received().

La règle reste : NSubstitute est utile pour les .Returns(). Pour les vérifications d’interactions, le spy est presque toujours préférable.

En résumé

  • .Received() avec arguments exacts → tests fragiles, cassent au moindre refactoring
  • .Received() avec Arg.Any<>() → tests aveugles, ratent les régressions réelles
  • Il n’existe pas de juste milieu facile avec .Received()
  • Un spy coûte cinq lignes, s’écrit une fois, et donne un contrôle total sur les assertions
  • Les librairies de mock sont légitimes pour les .Returns() et les interfaces tierces complexes — pas pour vérifier des interactions

Si vous avez des tests qui utilisent .Received(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>()), posez-vous la question : que vérifient-ils vraiment ?


Cet article fait suite à Stub, Mock, Fake : en finir avec la confusion sur les doublures de test, où les cinq types de doublures sont présentés en détail.