Stub, Mock, Fake : en finir avec la confusion sur les doublures de test


Le problème qu’on ne voit pas

Vous avez des tests. Vos tests utilisent NSubstitute ou Moq. Vous appelez ça des “mocks”.

Votre collègue aussi appelle ça des “mocks”. Votre tech lead aussi.

Et pourtant, vos tests cassent à chaque refactoring. Ils sont difficiles à comprendre. Ils vérifient comment le code fonctionne plutôt que ce qu’il fait. Chaque nouvelle fonctionnalité impose de réécrire dix tests existants.

Ce n’est pas un problème de framework. C’est 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 le principe d’inversion des dépendances (le D de SOLID).

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 vous vous retrouvez 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 — l’avion en papier

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. Mais 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.


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 vous testez un résultat — un état observable du système après l’action — utilisez un stub ou un fake. Vérifiez ce que le système contient ou retourne.

Si vous testez une interaction — le fait qu’un service externe a bien été appelé avec les bons arguments — utilisez un mock ou un spy. Mais demandez-vous d’abord si c’est vraiment le comportement qu’on veut garantir.

// ❌ 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érez les stubs et les fakes. Vérifiez les résultats. Testez 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 : testez-vous un résultat ou une interaction ? La réponse détermine le type de doublure.

Et la prochaine fois que vous créez automatiquement un Substitute.For<T>() suivi d’un .Received(), posez-vous 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 Strasbourg. Vous souhaitez que votre équipe maîtrise les doublures de test ? Contactez-moi pour un atelier sur mesure.