Comment sont différents mock, stub et fake
1. Vue d’ensemble
Lorsque vous écrivez des tests unitaires, vous devez souvent remplacer les vraies dépendances comme la base de données, la passerelle de paiement ou le service d’envoi d’e-mails par un objet de « substitution ». Ces objets sont généralement appelés test doubles. Beaucoup de gens les appellent tous des « mocks », mais mock, stub et fake servent trois objectifs différents.
Cet article vous aide à faire la distinction entre ces trois types à travers un même exemple, à savoir quand utiliser lequel et à éviter les erreurs qui rendent les tests fragiles ou sans intérêt.
2. Exemple utilisé tout au long de l’article
2.1. Dépendances
Dans cet article, nous utilisons JUnit 5, AssertJ et Mockito. Si vous utilisez Spring Boot 3.x, spring-boot-starter-test les inclut déjà tous les trois. Sinon, ajoutez les dépendances suivantes :
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>5.14.2</version>
<scope>test</scope>
</dependency>
</dependencies>
2.2. Code à tester
Nous avons un OrderService simple. Ce service collecte le paiement, enregistre la commande, puis envoie un e-mail de confirmation :
public record Order(String id, String customerEmail, BigDecimal amount) {}
public interface PaymentGateway {
boolean charge(String customerEmail, BigDecimal amount);
}
public interface OrderRepository {
void save(Order order);
Optional<Order> findById(String id);
}
public interface EmailSender {
void send(String to, String subject);
}
public class OrderService {
private final PaymentGateway paymentGateway;
private final OrderRepository orderRepository;
private final EmailSender emailSender;
public OrderService(PaymentGateway paymentGateway,
OrderRepository orderRepository,
EmailSender emailSender) {
this.paymentGateway = paymentGateway;
this.orderRepository = orderRepository;
this.emailSender = emailSender;
}
public boolean placeOrder(Order order) {
if (!paymentGateway.charge(order.customerEmail(), order.amount())) {
return false;
}
orderRepository.save(order);
emailSender.send(order.customerEmail(), "Commande " + order.id() + " confirmée");
return true;
}
}
OrderService a trois dépendances. Chaque dépendance est adaptée à un type de test double différent :
PaymentGatewayfournit les données d’entrée (résultat du paiement), elle est donc adaptée à un stub.EmailSenderest un effet secondaire qui doit être vérifié (l’e-mail a-t-il été envoyé ?), elle est donc adaptée à un mock.OrderRepositorya un vrai comportement de stockage, elle est donc adaptée à un fake.
3. Stub : retourner une réponse prédéfinie
Un stub est un objet qui retourne une valeur fixe pour les appels. Le stub ne se soucie pas de savoir combien de fois il est appelé. Vous ne faites pas non plus d’assertions sur le stub. Le seul objectif d’un stub est d’amener le code testé dans la bonne branche logique que vous souhaitez vérifier.
3.1. Stub écrit manuellement
Le moyen le plus simple est d’implémenter l’interface vous-même :
class DecliningPaymentGateway implements PaymentGateway {
@Override
public boolean charge(String customerEmail, BigDecimal amount) {
return false;
}
}
Cette classe refuse toujours le paiement. Nous l’utilisons pour tester la branche « paiement refusé » :
class OrderServiceStubTest {
@Test
void givenDeclinedPayment_whenPlaceOrder_thenReturnFalse() {
OrderService service = new OrderService(
new DecliningPaymentGateway(),
mock(OrderRepository.class),
mock(EmailSender.class));
boolean result = service.placeOrder(
new Order("ORD-1", "an@example.com", new BigDecimal("150000")));
assertThat(result).isFalse();
}
}
Dans ce test :
DecliningPaymentGatewayest un stub. Il forceplaceOrder()à entrer dans la branchereturn false.- L’assertion est sur le résultat retourné par le service, pas sur le stub lui-même.
mock(OrderRepository.class)etmock(EmailSender.class)jouent ici le rôle de dummy (figurant). Ce sont des objets passés pour remplir les paramètres du constructeur, qui ne sont pas vraiment utilisés.
3.2. Stub avec Mockito
Écrire manuellement une classe pour chaque scénario devient rapidement fastidieux. Mockito vous permet de créer des stubs directement dans le test :
@Test
void givenApprovedPayment_whenPlaceOrder_thenReturnTrue() {
PaymentGateway paymentGateway = mock(PaymentGateway.class);
when(paymentGateway.charge(anyString(), any(BigDecimal.class))).thenReturn(true);
OrderService service = new OrderService(
paymentGateway, mock(OrderRepository.class), mock(EmailSender.class));
assertThat(service.placeOrder(
new Order("ORD-2", "binh@example.com", new BigDecimal("99000"))))
.isTrue();
}
Bien qu’il soit créé avec la fonction mock(), paymentGateway est toujours un stub en tant que rôle : nous ne configurons que sa valeur de retour avec when(...).thenReturn(...) et nous ne le verify jamais. Le nom de la fonction de la bibliothèque ne détermine pas le type de test double. La façon dont vous utilisez l’objet est ce qui compte vraiment.
4. Mock : vérifier les interactions
Un mock est un objet qui enregistre les appels qui lui sont faits, afin que vous puissiez vérifier si le code a appelé la bonne méthode, avec les bons paramètres, le bon nombre de fois. Contrairement à un stub, l’assertion porte sur le mock lui-même.
Un mock est utile lorsque le comportement à vérifier est un effet secondaire qui ne retourne rien, par exemple envoyer un e-mail, publier un événement ou enregistrer un événement d’audit.
@ExtendWith(MockitoExtension.class)
class OrderServiceMockTest {
@Mock
PaymentGateway paymentGateway;
@Mock
OrderRepository orderRepository;
@Mock
EmailSender emailSender;
@InjectMocks
OrderService orderService;
@Test
void givenApprovedPayment_whenPlaceOrder_thenSendConfirmationEmail() {
when(paymentGateway.charge("an@example.com", new BigDecimal("150000")))
.thenReturn(true);
orderService.placeOrder(new Order("ORD-1", "an@example.com", new BigDecimal("150000")));
verify(emailSender).send("an@example.com", "Commande ORD-1 confirmée");
}
@Test
void givenDeclinedPayment_whenPlaceOrder_thenNoEmailSent() {
when(paymentGateway.charge(anyString(), any(BigDecimal.class))).thenReturn(false);
orderService.placeOrder(new Order("ORD-1", "an@example.com", new BigDecimal("150000")));
verifyNoInteractions(emailSender, orderRepository);
}
}
Analysons chaque partie :
@ExtendWith(MockitoExtension.class)active Mockito pour JUnit 5. Les champs@Mocksont initialisés avant chaque test.@InjectMockscréeOrderServiceet injecte les trois objets@Mockvia le constructeur.paymentGatewayest toujours un stub, tandis queemailSenderest le vrai mock car nous leverify.verify(emailSender).send(...)vérifie quesend()est appelé exactement une fois avec exactement deux paramètres.verifyNoInteractions(...)affirme que lorsque le paiement échoue, le système n’enregistre pas la commande et n’envoie pas d’e-mail.
Si vous supprimez accidentellement la ligne emailSender.send(...) dans OrderService, le premier test échouera avec un message similaire à :
Wanted but not invoked:
emailSender.send(
"an@example.com",
"Commande ORD-1 confirmée"
);
-> at OrderServiceMockTest.givenApprovedPayment_whenPlaceOrder_thenSendConfirmationEmail
Actually, there were zero interactions with this mock.
Remarque : Vous ne devez pas à la fois stub et vérifier le même appel, par exemple
when(paymentGateway.charge(...))puisverify(paymentGateway).charge(...). Si le stub n’est pas appelé, le service n’a pas retournétrueet le test échouera ailleurs. La commandeverifyest alors superflue et rend simplement le test plus étroitement lié à l’implémentation.
5. Fake : une vraie implémentation mais simplifiée
Un fake est une implémentation qui fonctionne réellement mais utilise un raccourci inapproprié pour la production. L’exemple typique est un repository qui stocke les données dans une HashMap au lieu d’une base de données :
public class InMemoryOrderRepository implements OrderRepository {
private final Map<String, Order> storage = new HashMap<>();
@Override
public void save(Order order) {
storage.put(order.id(), order);
}
@Override
public Optional<Order> findById(String id) {
return Optional.ofNullable(storage.get(id));
}
}
Contrairement à un stub, un fake a une logique et un état. Appeler save() puis findById() retournera la commande que vous venez de sauvegarder. Grâce à cela, vous vérifiez l’état final plutôt que les appels :
class OrderServiceFakeTest {
private final InMemoryOrderRepository orderRepository = new InMemoryOrderRepository();
private final OrderService orderService = new OrderService(
(email, amount) -> true, // stub sous forme de lambda
orderRepository, // fake
(to, subject) -> { }); // dummy sous forme de lambda
@Test
void givenApprovedPayment_whenPlaceOrder_thenOrderIsPersisted() {
Order order = new Order("ORD-3", "chi@example.com", new BigDecimal("250000"));
orderService.placeOrder(order);
assertThat(orderRepository.findById("ORD-3")).contains(order);
}
}
Quelques points à noter :
PaymentGatewayetEmailSendern’ont chacun qu’une seule méthode abstraite, donc nous créons le stub et le dummy avec des lambdas sans avoir besoin de Mockito.- Le test ne sait pas combien de fois
OrderServiceappellesave()ou dans quel ordre. Le test se soucie uniquement de après la commande, la commande se trouve-t-elle dans le système ?. - Si plus tard vous refactorisez
OrderServicepour utiliser une autre méthode, par exemplesaveAll(), le test utilisant fake continuera de fonctionner tant que le résultat final est correct. Le test utilisantverify(orderRepository).save(order)se cassera.
Remarque : Les fakes sont du code, donc les fakes peuvent aussi avoir des bugs. Si un fake se comporte différemment de la vraie implémentation (par exemple, il ne lève pas d’erreur en cas de clé en double), le test peut réussir alors que la production échoue. Pour les fakes importants, écrivez un ensemble de tests de contrat partagés, qui s’exécutent à la fois sur le fake et sur la vraie implémentation (par exemple, la version JPA exécutée avec Testcontainers).
6. Comparaison et comment choisir
Le tableau ci-dessous résume les différences :
| Critère | Stub | Mock | Fake |
|---|---|---|---|
| Objectif | Fournir les données d’entrée | Vérifier les interactions | Remplacer le vrai comportement de manière plus légère |
| A une logique | Non, retourne une valeur fixe | Non, enregistre seulement les appels | Oui, fonctionne comme la vraie version |
| Assertion sur | Le résultat du code testé | Le mock lui-même (verify) |
L’état du fake |
| Type de test | State-based | Interaction-based | State-based |
| Robustesse en refactorisation | Haute | Plus basse | Haute |
Au-delà de ces trois types, vous rencontrerez deux autres types :
- Dummy : un objet passé en paramètre pour compléter la liste, jamais vraiment utilisé.
- Spy : encapsule un objet réel, exécute toujours la logique réelle mais enregistre les appels pour les
verify. Dans Mockito, vous créez un spy avecspy(...).
Une règle simple pour choisir :
- Si une dépendance retourne des données pour votre code, utilisez un stub.
- Si une dépendance est un effet secondaire vers l’extérieur (e-mail, file d’attente de messages, API tierce) et qu’il n’y a aucun moyen d’observer son état, utilisez un mock.
- Si une dépendance a un état et est utilisée dans de nombreux tests (repository, cache, horloge), envisagez d’écrire un fake une fois et de le réutiliser partout.
En général, privilégiez les tests basés sur l’état (stub, fake) plutôt que les tests basés sur les interactions (mock). Les tests basés sur l’état décrivent le résultat, tandis que les tests basés sur les interactions décrivent le comment, et le comment change plus souvent.
7. Erreurs courantes
Tout mocker. Si un test a cinq commandes when et quatre commandes verify, ce test copie simplement l’implémentation plutôt que de vérifier le comportement. Il suffit de changer l’ordre de deux lignes de code et le test échouera même si le résultat est correct.
Mocker les objets valeur. Ne moquez pas Order, BigDecimal ou LocalDate. Créez des instances réelles. Mockito 5 peut par défaut mocker les classes final et les records, mais juste parce que vous pouvez le faire ne signifie pas que vous devriez.
Mocker les types de données que vous ne possédez pas. Mocker directement RestTemplate ou le SDK d’un fournisseur fait que le test suppose à tort le comportement de la bibliothèque. Enveloppez la bibliothèque derrière une interface que vous possédez (comme PaymentGateway) et moquez cette interface. Vérifiez la vraie implémentation avec un test d’intégration.
Stubs inutiles. Avec MockitoExtension, Mockito utilise par défaut des stubs stricts. Si vous stubifiez un appel que le code ne fait jamais, le test échouera :
org.mockito.exceptions.misusing.UnnecessaryStubbingException:
Unnecessary stubbings detected.
Clean & maintainable test code requires zero unnecessary code.
N’empressez pas de désactiver cet avertissement avec lenient(). Ce message indique généralement que le test n’entre pas dans la branche que vous pensiez, ou que la partie setup est obsolète.
Utiliser les fakes à la place des tests d’intégration. InMemoryOrderRepository ne peut pas vérifier les requêtes SQL, les contraintes ou les transactions. Les fakes permettent des tests unitaires rapides, mais vous avez toujours besoin de quelques tests d’intégration avec une vraie base de données.
8. Conclusion
Les stubs, les mocks et les fakes sont tous des test doubles mais répondent à trois questions différentes. Un stub répond à « que retourne la dépendance ? ». Un mock répond à « le code appelle-t-il la dépendance correctement ? ». Un fake répond à « si nous avions une vraie implémentation légère, quel serait le résultat final ? ».
Choisissez le type de test double selon le rôle de la dépendance, pas selon l’outil : un objet créé avec mock() peut tout à fait n’être qu’un stub. Privilégiez les tests basés sur l’état, n’utilisez verify que pour les effets secondaires qui ont réellement besoin d’être vérifiés. Vos tests seront plus courts, plus lisibles et moins fragiles lors d’une refactorisation.