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.
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.
Il en existe cinq, chacun avec un rôle précis et différent.
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")));
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));
}
}
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);
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.
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);
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 :
Le choix entre ces doublures dépend aussi du type de vérification qu'on veut effectuer.
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.
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.
Tout dépend du contexte :
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.
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 :
when(...).thenReturn(...)La solution : encapsuler la doublure dans une classe maison. Le test ne voit que l'interface, plus les détails.
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.
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 :
Vérification de comportement pur. Quand il n'y a pas d'état à observer — publication d'un événement sur un bus, envoi d'un e-mail, appel à une API tierce — un verify() est exactement le bon outil. Écrire un Fake pour un EventPublisher juste pour capturer les appels, c'est réinventer ce que Mockito fait déjà très bien.
Prototypage rapide et exploration. Quand on écrit les premiers tests d'un module dont les interfaces ne sont pas encore stabilisées, un when().thenReturn() permet d'avancer vite sans investir dans des Fakes qu'on réécrira trois fois. L'important est de revenir nettoyer une fois le design posé.
Tests ponctuels de cas limites. Simuler une exception réseau, un timeout, un retour null inattendu — forcer un Fake à reproduire ces scénarios demande parfois plus d'effort que ça n'en vaut. Un Stub Mockito ciblé fait le travail proprement.
Petites codebases ou équipes en montée en compétence. Quand la base de tests compte quelques centaines de tests et que l'équipe découvre encore les doublures, le coût d'apprentissage et de maintenance de Fakes maison peut dépasser le bénéfice. Mieux vaut un test Mockito bien écrit qu'un Fake mal maintenu.
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.
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.
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.
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.
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.
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é.
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é.
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.
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 :
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.