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 :

  • PaymentGateway fournit les données d’entrée (résultat du paiement), elle est donc adaptée à un stub.
  • EmailSender est un effet secondaire qui doit être vérifié (l’e-mail a-t-il été envoyé ?), elle est donc adaptée à un mock.
  • OrderRepository a 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 :

  • DecliningPaymentGateway est un stub. Il force placeOrder() à entrer dans la branche return false.
  • L’assertion est sur le résultat retourné par le service, pas sur le stub lui-même.
  • mock(OrderRepository.class) et mock(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 @Mock sont initialisés avant chaque test.
  • @InjectMocks crée OrderService et injecte les trois objets @Mock via le constructeur.
  • paymentGateway est toujours un stub, tandis que emailSender est le vrai mock car nous le verify.
  • verify(emailSender).send(...) vérifie que send() 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(...)) puis verify(paymentGateway).charge(...). Si le stub n’est pas appelé, le service n’a pas retourné true et le test échouera ailleurs. La commande verify est 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 :

  • PaymentGateway et EmailSender n’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 OrderService appelle save() 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 OrderService pour utiliser une autre méthode, par exemple saveAll(), le test utilisant fake continuera de fonctionner tant que le résultat final est correct. Le test utilisant verify(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 avec spy(...).

Une règle simple pour choisir :

  1. Si une dépendance retourne des données pour votre code, utilisez un stub.
  2. 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.
  3. 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.

testingmockitojunit5unit-testjava