Mocks, Stubs & Co. : comprendre ses alliés avant qu'ils ne vous trahissent

Talk présenté dans le cadre d'un Brown Bag Lunch — mai 2025


Vous avez déjà vu un test passer au vert alors que le code était cassé ? Ou une suite de tests qui explose dès qu'on renomme une méthode privée ? Ces symptômes ont souvent la même origine : une doublure de test mal choisie.

On les appelle tous "mocks". C'est là que tout déraille.


Ce qu'est un bon test

Avant de plonger dans les types de doublures, posons les bases. Un bon test doit être :

Le problème, c'est qu'en tests unitaires, on veut tester un comportement précis, mais le code testé dépend souvent de systèmes externes : bases de données, clients HTTP, système d'horloge… Impossible de les embarquer dans chaque test unitaire. C'est là qu'entrent les doublures de test.


Les 5 types de doublures de test

Il en existe cinq, chacun avec un rôle précis et différent.

Stub

Un objet dont les réponses sont codées en dur. Peu importe ce qu'on lui passe, il retourne toujours la même chose.

// Stub : retourne toujours la même liste, peu importe l'entrée
when(repository.findAll()).thenReturn(List.of(new Drug("paracetamol")));

Fake

Une implémentation légère mais fonctionnelle d'une dépendance. Par exemple, une base de données en mémoire qui contient juste assez de logique pour simuler le comportement réel sans en reproduire la complexité.

public class InMemoryDrugRepository implements DrugRepositoryPort {
    private final Map<String, Drug> store = new HashMap<>();

    public Drug save(Drug drug) {
        store.put(drug.getId(), drug);
        return drug;
    }

    public Optional<Drug> findById(String id) {
        return Optional.ofNullable(store.get(id));
    }
}

Mock

Un objet qui enregistre les interactions pour qu'on puisse les vérifier ensuite : telle méthode a-t-elle été appelée ? Avec quel paramètre ? Combien de fois ?

// Vérification que save() a bien été appelé avec le bon objet
verify(repository).save(expectedDrug);

Spy

Une doublure hybride : elle s'appuie sur l'implémentation réelle, mais permet en plus d'observer les interactions et de surcharger certains comportements ponctuellement. C'est l'implémentation du pattern Decorator.

Dummy

Un objet passé en paramètre uniquement pour satisfaire une signature de méthode, sans jamais être utilisé dans le test lui-même.

// Logger jamais utilisé, juste là pour que le constructeur compile
var service = new OrderService(new DummyLogger(), realRepository);

Pourquoi tout le monde dit "mock" pour tout ?

La confusion vient en grande partie des frameworks comme Mockito. En cherchant à simplifier la création de ces objets, Mockito utilise le mot "mock" de façon très générique — pour tout créer. Sa propre FAQ le reconnaît d'ailleurs : Mockito est davantage un framework de Spy qu'un framework de Mock au sens strict.

Résultat : on ne sait plus ce qu'on crée. On pense faire un Mock, on fait en réalité un Stub. Et c'est là que les ennuis commencent.

En réalité, ce n'est pas l'outil qui détermine le type de doublure — c'est votre intention :


State verification vs Behavior verification

Le choix entre ces doublures dépend aussi du type de vérification qu'on veut effectuer.

Vérification d'état (State verification)

On vérifie l'état de l'objet après l'action. Le test ne s'intéresse pas à comment on y est arrivé, seulement au résultat.

// On dépose 100€
account.deposit(100);

// On vérifie l'état : le solde est-il correct ?
assertThat(account.getBalance()).isEqualTo(100);

Stubs et Fakes sont les plus adaptés ici.

Vérification de comportement (Behavior verification)

On vérifie non pas ce qui a été produit, mais comment le code y est arrivé : quelles interactions ont eu lieu ?

// On dépose 100€
account.deposit(100);

// On vérifie l'interaction : save() a-t-il bien été appelé avec le bon montant ?
verify(repository).save(argThat(t -> t.getAmount() == 100));

Mocks et Spies entrent en jeu ici.

Lequel choisir ?

Tout dépend du contexte :


Quand les alliés incompris nous trahissent

Piège n°1 : le faux positif silencieux

Les faux positifs les plus dangereux ne sont pas ceux qu'on repère au premier coup d'œil. Ils se cachent derrière des tests qui semblent raisonnables.

Prenons un cas concret. On a un service qui désactive une pénurie de médicament expirée. Le service doit récupérer la pénurie, passer son statut à INACTIVE, puis persister la modification.

// ❌ Test avec Stub Mockito — faux positif caché
@Test
void shouldDeactivateExpiredShortage() {
    DrugShortage active = new DrugShortage("id-1", "paracetamol", Status.ACTIVE);
    DrugShortage deactivated = new DrugShortage("id-1", "paracetamol", Status.INACTIVE);

    when(repository.findById("id-1")).thenReturn(Optional.of(active));
    when(repository.update(any(DrugShortage.class))).thenReturn(deactivated); // réponse pré-câblée

    DrugShortage result = service.deactivate("id-1");

    assertThat(result.getStatus()).isEqualTo(Status.INACTIVE); // toujours vrai grâce au Stub
}

Ce test passe au vert même si le service appelle repository.update(active) sans jamais changer le statut. Le Stub retourne deactivated quoi qu'il arrive — le résultat est décidé à l'avance dans le setup, pas par la logique métier.

Ce qui rend ce piège insidieux, c'est que le test a l'air correct : une assertion métier claire, un setup qui ressemble à de l'orchestration réelle. Pourtant, on valide la sortie du Stub, pas la transformation effectuée par le service.

La solution : remplacer le Stub par un Fake qui exécute une vraie logique de persistance.

// ✅ Test avec un Fake maison — le faux positif disparaît
@Test
void shouldDeactivateExpiredShortage() {
    var repository = new InMemoryDrugShortageRepository();
    var service = new DrugShortageService(repository);

    repository.save(new DrugShortage("id-1", "paracetamol", Status.ACTIVE));

    DrugShortage result = service.deactivate("id-1");

    assertThat(result.getStatus()).isEqualTo(Status.INACTIVE);
}

Visuellement, ce test ressemble au précédent. Mais la différence est fondamentale : ici, aucun when().thenReturn() ne pré-câble la réponse. Le résultat vient de l'exécution réelle à travers le Fake. Si le service oublie de changer le statut, result contient ACTIVE et l'assertion échoue.

Piège n°2 : le couplage fort avec l'implémentation

Avec les stubs Mockito, le test doit connaître les détails internes du code de production.

// Le test sait que le service appelle update(), puis findById()
// Si on renomme ou restructure ces méthodes → le test explose
when(repository.update(obj)).thenReturn(obj);
when(repository.findById("id-1")).thenReturn(Optional.of(obj));

Ce couplage fort a des conséquences concrètes :

La solution : encapsuler la doublure dans une classe maison. Le test ne voit que l'interface, plus les détails.

Piège n°3 : le coût caché de Mockito

Voici des chiffres mesurés en conditions réelles :

Approche Durée d'un test
@Mock + @InjectMocks ~658 ms
Mockito.mock(Xxx.class) ~58 ms
Fake/Stub maison ~6 ms

L'explication : l'annotation @Mock déclenche un scanner de classpath et un runner Mockito à chaque exécution. Mockito.mock() évite le scan mais garde le coût du runner. Un Fake maison n'implique rien de tout ça.

À l'échelle d'une codebase avec 3 000+ @Mock et 11 000+ Mockito.mock(...), ça représente +30 secondes par pipeline. Sur 248 pipelines par jour, c'est plus de 2 heures de CI gaspillées quotidiennement.


Quand Mockito reste le bon choix

Après tout ça, on pourrait croire que Mockito est à proscrire. Ce n'est pas le message.

Mockito reste pertinent dans plusieurs situations concrètes :

L'enjeu n'est pas de bannir un outil, c'est de choisir en conscience. Mockito est un excellent serviteur quand on sait ce qu'on lui demande — le problème apparaît quand il devient le réflexe par défaut, appliqué sans se poser la question du type de doublure.


Comment faire autrement ?

1. Réduire les dépendances externes

Avant même d'écrire les tests, cherchez à limiter les dépendances dans votre code. Une classe avec trop de dépendances fait peut-être trop de choses — c'est aussi un signal de design.

2. Inverser les dépendances

Introduire une interface entre deux composants permet de découpler l'appelant de l'implémentation concrète :

// Avant : dépendance directe → impossible à remplacer en test
class DrugService {
    private DrugShortageJpaRepository repository; // implémentation concrète
}

// Après : dépendance sur une interface → on peut injecter n'importe quoi
class DrugService {
    private DrugShortageRepositoryPort repository; // interface
}

En test, on injecte le Fake. En production, l'implémentation JPA. La logique métier ne change pas.

3. Créer des Fakes et Stubs maison

Une fois l'interface en place, le Fake s'écrit en quelques lignes :

public class InMemoryDrugShortageRepository implements DrugShortageRepositoryPort {
    private final Map<String, DrugShortage> store = new HashMap<>();

    @Override
    public DrugShortage update(DrugShortage shortage) {
        store.put(shortage.getId(), shortage);
        return shortage;
    }

    @Override
    public Optional<DrugShortage> findById(String id) {
        return Optional.ofNullable(store.get(id));
    }
}

Cette classe peut être partagée entre tous les tests qui en ont besoin, maintenue comme du code de production, et évoluée indépendamment des tests.


Bonus : les Fakes au-delà des tests

Un avantage souvent sous-estimé des Fakes : ils ne sont pas cantonnés aux tests. Dès lors que votre architecture repose sur des interfaces (ports), un Fake devient une implémentation à part entière — légère, sans infrastructure, prête à l'emploi.

Prototypage et exploration du domaine

Quand le domaine métier n'est pas encore stabilisé, partir directement sur un schéma de base de données fige des décisions trop tôt. Un InMemoryRepository permet d'itérer librement sur le modèle : ajouter un champ, changer une relation, repenser une agrégation — tout ça sans migration SQL ni contrainte d'infrastructure.

Une fois le domaine bien modélisé et les invariants clairement identifiés, on branche la vraie implémentation. Le code métier n'a pas bougé.

Démonstrations et environnements jetables

Un Fake permet aussi de faire tourner l'application sans aucune dépendance externe. C'est précieux pour les démos en clientèle, les revues de sprint, ou les environnements de développement local. Plus besoin d'un Docker Compose à 12 services pour montrer une fonctionnalité.

Mode dégradé

Dans certains contextes, un Fake peut même servir de fallback en production. Si le service de notification est indisponible, un NoOpNotificationService qui implémente la même interface permet à l'application de continuer à fonctionner sans crasher — tout en loggant l'incident pour traitement ultérieur.

Ces usages ne sont pas anecdotiques. Ils découlent naturellement d'une architecture qui repose sur l'inversion de dépendances. Les Fakes écrits pour les tests deviennent des briques réutilisables dans tout le cycle de vie du logiciel.


Conclusion

Les doublures de test sont différentes et ne sont pas interchangeables. Utiliser un Stub là où il faudrait un Fake, c'est s'exposer à des faux positifs silencieux qui donnent une fausse confiance dans la suite de tests.

À retenir :

  1. Identifier le type de vérification avant de choisir la doublure : état ou comportement ?
  2. Privilégier les Fakes et Stubs maison pour la vérification d'état — plus rapides, plus lisibles, moins fragiles
  3. Utiliser Mockito là où il excelle — vérification de comportement, cas limites, prototypage — mais en connaissance de cause
  4. C'est nous qui maîtrisons le framework — pas le framework qui dicte nos choix

Un Mock maison, c'est un pattern Proxy. Un Spy maison, c'est un pattern Decorator. Ces concepts existaient avant Mockito et fonctionnent parfaitement sans lui. Comprendre ce qui se passe derrière l'outil, c'est ce qui fait la différence entre un test fragile et un test solide.


Ressources