Les 5 doublures de test : en finir avec la confusion sur les mocks


Le problème qu’on ne voit pas

Tout le monde appelle ça des “mocks”. Toi, tes collègues, ton tech lead.

Et pourtant, tes tests cassent à chaque refactoring. Ils sont illisibles. Ils vérifient comment le code fonctionne au lieu de ce qu’il fait. Chaque nouvelle fonctionnalité t’oblige à réécrire dix tests existants.

Ce n’est probablement pas un problème de framework, mais un problème de type de doublure.

D’où viennent ces termes ?

Les cinq types de doublures de test ont été définis par Gerard Meszaros dans xUnit Test Patterns (2007). Le terme générique est test double, par analogie avec la doublure de cinéma qui remplace l’acteur principal dans les scènes dangereuses.

L’idée : dans un test, on remplace les vraies dépendances (base de données, service d’email, horloge système) par des doublures qui sont rapides, contrôlables et sans effets de bord.

Mais il y a cinq types, pas un. Et chacun a un rôle précis.

Le pré-requis : l’injection de dépendances

Avant de présenter les doublures, un point important : pour pouvoir remplacer une dépendance par une doublure, il faut que le code accepte des interfaces, pas des implémentations concrètes.

// ❌ Impossible à tester — dépendance créée en interne
public class NotificationService
{
    public void NotifyUser(int userId, string message)
    {
        var db = new SqlConnection("Server=prod;...");
        var user = db.Query<User>("SELECT * FROM Users WHERE Id = @id", new { id = userId });
        var smtp = new SmtpClient("smtp.gmail.com");
        smtp.Send(new MailMessage("noreply@app.com", user.Email, "Notification", message));
    }
}
// ✅ Testable — dépendances injectées via le constructeur
public class NotificationService
{
    private readonly IUserRepository _users;
    private readonly IEmailSender _emailSender;

    public NotificationService(IUserRepository users, IEmailSender emailSender)
    {
        _users = users;
        _emailSender = emailSender;
    }

    public void NotifyUser(int userId, string message)
    {
        var user = _users.GetById(userId);
        if (user is null) return;
        _emailSender.Send(user.Email, "Notification", message);
    }
}

Maintenant que le code dépend d’interfaces, on peut injecter n’importe quelle implémentation, y compris des doublures. C’est la base de tout test unitaire : isoler la classe testée de ses dépendances réelles pour pouvoir contrôler son environnement et vérifier son comportement.

Les cinq types

1. Dummy — le figurant

Un dummy remplit un paramètre obligatoire mais n’est jamais utilisé dans le test.

[Fact]
public void CreateOrder_WithTwoItems_CalculatesCorrectTotal()
{
    // Le logger est obligatoire dans le constructeur mais n'intervient pas
    // dans le calcul du total — on lui passe quelque chose qui ne fait rien
    var dummyLogger = Substitute.For<ILogger>();

    var service = new OrderService(dummyLogger);
    var order = service.CreateOrder(items: new[] { 10m, 20m });

    Assert.Equal(30m, order.Total);
}

Le dummy dit : “cette dépendance n’a rien à voir avec ce que je teste.” Son seul rôle est de satisfaire la signature du constructeur.

On rencontre beaucoup de dummies dans les projets legacy, où les constructeurs accumulent des dépendances au fil des années sans refactoring. Si tu te retrouves régulièrement à en créer, c’est déjà un signal : la classe testée fait probablement trop de choses. C’est un symptôme de faible cohésion.


2. Stub — le répondeur automatique

Un stub retourne des réponses prédéfinies. Il contrôle ce que la dépendance renvoie, sans logique ni vérification.

[Fact]
public void GetGreeting_At8AM_ReturnsBonjourMessage()
{
    // Le stub contrôle l'heure perçue par le service
    var stubClock = Substitute.For<IClock>();
    stubClock.Now.Returns(new DateTime(2025, 1, 1, 8, 0, 0));

    var service = new GreetingService(stubClock);

    var greeting = service.GetGreeting();

    Assert.Equal("Bonjour !", greeting);
}

Le stub dit : “peu importe ce qui se passe dehors, ma dépendance me retourne toujours cette valeur.” C’est l’outil le plus courant pour contrôler l’environnement du test.

On ne vérifie pas que le stub a été appelé. On se fiche de comment. On veut juste décider ce qu’il retourne.


3. Fake — le vélo d’appartement

Un fake est une implémentation simplifiée mais fonctionnelle. Là où un stub retourne des valeurs codées en dur, un fake a une vraie logique interne, juste allégée.

On veut tester le comportement, pas l’implémentation : ce que le système fait, pas comment il le fait en interne. Le test s’écrit d’abord :

[Fact]
public void RegisterUser_NewUser_CanBeRetrievedAfterRegistration()
{
    var fakeRepo = new FakeUserRepository();
    var service = new UserRegistrationService(fakeRepo);

    service.Register(new User { Id = 1, Name = "Alice" });

    var users = fakeRepo.GetAll();
    Assert.Single(users);
    Assert.Equal("Alice", users[0].Name);
}

Le test décrit un comportement métier : un utilisateur enregistré peut être retrouvé. Peu importe comment UserRegistrationService l’implémente en interne. Pour que ce test fonctionne sans base de données, on écrit le fake :

public class FakeUserRepository : IUserRepository
{
    private readonly List<User> _users = new();

    public void Add(User user) => _users.Add(user);
    public User? GetById(int id) => _users.FirstOrDefault(u => u.Id == id);
    public IReadOnlyList<User> GetAll() => _users.AsReadOnly();
}

Le fake remplace une vraie base de données par une List<T> en mémoire. Il fonctionne vraiment, les données sont sauvées et récupérées. C’est un peu comme un vélo d’appartement : ça pédale, mais sans route ni météo. Côté code, sans serveur, sans réseau, sans latence.

Important : on ne teste pas le fake. On teste le service métier en utilisant le fake comme infrastructure.


4. Mock — la caméra de surveillance

Un mock vérifie qu’une interaction a bien eu lieu. Il ne s’intéresse pas au résultat observable, il s’intéresse à comment le code a appelé ses dépendances.

[Fact]
public void NotifyUser_ExistingUser_SendsEmailToCorrectAddress()
{
    var fakeRepo = new FakeUserRepository();
    fakeRepo.Add(new User { Id = 1, Email = "alice@test.com" });

    var mockSender = Substitute.For<IEmailSender>();
    var service = new NotificationService(fakeRepo, mockSender);

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

    // Le mock vérifie QUE l'envoi a eu lieu, avec les bons arguments
    mockSender.Received(1).Send("alice@test.com", "Notification", "Bienvenue !");
}

Le mock est couplé à l’implémentation interne. Si demain on renomme la méthode Send en Dispatch, ou si on change l’ordre des paramètres, le test casse, même si le comportement visible est identique.

C’est le type de doublure le plus puissant et le plus dangereux.

Mon opinion : le gain des mocks sur les spies est très mince. Un spy se résume à une classe avec une liste et une méthode qui y ajoute des éléments, quelques lignes, une fois, réutilisables dans tous les tests. Les risques des mocks, eux, sont bien réels : couplage à l’implémentation, tests fragiles, refactoring freiné. Le rapport coût/bénéfice ne tient pas. Quand on a besoin de vérifier une interaction, le spy suffit presque toujours.

J’ai développé ce point dans Pourquoi les librairies de mock sont (presque toujours) un piège.


5. Spy — le magnétophone

Un spy enregistre les appels pour qu’on puisse faire des assertions après coup. Contrairement au mock, il ne vérifie rien lui-même, c’est le test qui fait les assertions.

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_SendsExactlyOneEmail()
{
    var spy = new SpyEmailSender();
    var fakeRepo = new FakeUserRepository();
    fakeRepo.Add(new User { Id = 1, Email = "alice@test.com" });

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

    Assert.Single(spy.SentEmails);
    Assert.Equal("alice@test.com", spy.SentEmails[0].To);
}

La différence avec le mock : le spy est passif. Il collecte, sans juger. C’est le test qui décide ce qui est important. Cela donne plus de flexibilité dans les assertions.

En pratique, mock et spy sont souvent confondus, y compris dans les frameworks (NSubstitute fait les deux). La nuance conceptuelle reste utile pour comprendre ce qu’on teste.

Récapitulatif

TypeCe qu’il faitCe qu’il vérifieQuand l’utiliser
DummyRienRienParamètre obligatoire mais inutilisé
StubRetourne des valeurs fixesRienContrôler l’environnement (retours de dépendances)
FakeImplémentation simplifiéeRienRepositories, caches, files d’attente
MockProgrammableVérifie les interactionsInteractions critiques, avec parcimonie
SpyEnregistre les appelsAssertions sur les appelsVérifier ce qui a été envoyé/publié

La règle d’or : résultats vs interactions

La vraie question à se poser avant d’écrire un test n’est pas “quel framework utiliser ?” mais :

Est-ce que je teste un résultat ou une interaction ?

Si tu testes un résultat, un état observable du système après l’action, utilise un stub ou un fake. Vérifie ce que le système contient ou retourne.

Si tu testes une interaction, le fait qu’un service externe a bien été appelé avec les bons arguments, utilise un mock ou un spy. Mais demande-toi d’abord si c’est vraiment le comportement qu’on veut garantir.

⚠️ Attention, les interactions sont souvent des détails d’implémentation qui rendent les tests fragiles. ⚠️

// ❌ Teste l'implémentation : cassera si on refactore
[Fact]
public void ProcessOrder_CallsRepositoryThenSendsEmail()
{
    var repo = Substitute.For<IOrderRepository>();
    var sender = Substitute.For<IEmailSender>();
    var service = new OrderService(repo, sender);

    service.Process(new Order { Id = 1 });

    // On vérifie COMMENT le code fonctionne en interne
    repo.Received(1).Save(Arg.Any<Order>());
    sender.Received(1).Send(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>());
}

// ✅ Teste le comportement : résiste au refactoring
[Fact]
public void ProcessOrder_SavesOrderAndNotifiesCustomer()
{
    var fakeRepo = new FakeOrderRepository();
    var spy = new SpyEmailSender();
    var service = new OrderService(fakeRepo, spy);

    service.Process(new Order { Id = 1, CustomerEmail = "bob@test.com" });

    // On vérifie CE QUE le système fait
    Assert.Single(fakeRepo.GetAll());
    Assert.Contains(spy.SentEmails, e => e.To == "bob@test.com");
}

Le test de droite peut survivre à une extraction de méthode, un renommage, ou une réorganisation interne. Le test de gauche casse au moindre mouvement.

L’over-mocking : le symptôme le plus courant

Dans la plupart des codebases que j’ai rencontrées, le problème n’est pas “les développeurs n’utilisent pas les doublures”. C’est “ils utilisent uniquement des mocks”.

Résultat :

  • Les tests vérifient chaque appel de méthode
  • Un refactoring anodin casse dix tests
  • Les tests deviennent incompréhensibles à force de .Received().Method(Arg.Any<Type>(), ...)
  • L’équipe arrête de refactorer pour ne pas casser les tests
  • Le code se dégrade

Les mocks sont utiles. Mais ils sont l’outil de dernier recours, pour les cas où l’interaction elle-même est le comportement à garantir : envoyer un email, publier un événement, appeler un service de paiement. Pas pour vérifier que UserRepository.FindById a bien été appelé avec l’id 42.

Pour tout le reste, et c’est la majorité des cas, préfère les stubs et les fakes. Vérifie les résultats. Teste le comportement.

En résumé

  • Dummy : remplit un paramètre, n’est jamais utilisé
  • Stub : contrôle ce que la dépendance retourne
  • Fake : remplace une infrastructure lourde par une version légère et fonctionnelle
  • Mock : vérifie qu’une interaction a eu lieu (à utiliser avec parcimonie)
  • Spy : enregistre les appels pour des assertions flexibles

La question centrale : tu testes un résultat ou une interaction ? La réponse détermine le type de doublure.

Et la prochaine fois que tu crées automatiquement un Substitute.For<T>() suivi d’un .Received(), pose-toi la question : est-ce vraiment l’interaction qui importe ici, ou est-ce que je pourrais simplement vérifier le résultat avec un fake ?


Cet article s’appuie sur le contenu de mes formations Tests & Validation dispensées au CNAM et chez Ynov à Strasbourg. Tu veux que ton équipe maîtrise les doublures de test ? Contacte-moi pour un atelier sur mesure.