CodeGym /Cours /Spring Test /Mockito : des tests verts sans signification

Mockito : des tests verts sans signification

Spring Test
Niveau 4, Leçon 4
Disponible

1. Le test « vert » et la fausse confiance

Le piège le plus insidieux de Mockito, c'est qu'il est très facile de rendre un test vert. Deux lignes de when(...).thenReturn(...), un assertThat(...), et on a l'impression d'être maître de la qualité. Le problème, c'est que Mockito est tellement obéissant que parfois on ne teste pas le code de ContentHub, mais sa propre capacité à écrire un scénario pour des marionnettes. Et le test reste vert même quand tout est cassé en production.

L'exemple le plus « honnête » de test inutile ressemble à ceci : on mocke la classe testée elle-même et on vérifie que le mock retourne ce qu'on y a mis à l'avance. Ce n'est pas un test, c'est « bonjour, j'ai réussi à stocker le nombre 5 dans une variable ».

Voici un anti-exemple jouet intentionnel avec un nom de classe neutre. Ce qui compte, c'est l'odeur de l'anti-pattern ; la signature exacte de ce morceau de code n'a pas d'importance.

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;

class GreenButUselessTest {

    @Test
    void mocks_the_class_under_test_and_proves_nothing() {
        // Anti-pattern: on mocke la classe elle-même qu'on devrait tester
        ReviewDecisionService service = mock(ReviewDecisionService.class);

        // On "injecte" à l'avance la réponse dans le mock — ce n'est pas une vérification de logique métier
        when(service.submitForReview(42L)).thenReturn("OK");

        // On vérifie que le mock retourne ce qu'on lui a nous-même dit de retourner
        assertThat(service.submitForReview(42L)).isEqualTo("OK");
    }
}

Le test est vert. Sauf qu'il prouve exactement une chose : Mockito sait retourner des valeurs prédéfinies. Si demain le vrai service commence à retourner "FAIL", le test ne le verra pas — car il n'a jamais vu le vrai code.

Pour sentir qu'un test est utile, il est pratique de se poser une question désagréable : « Si je casse le code de production, ce test deviendra-t-il rouge ? ». Si la réponse est « non » ou « pas sûr », alors votre test vert est un voyant vert sur le tableau de bord, branché non pas sur le moteur, mais sur la pile du trousseau de clés.

Dans le monde de Mockito, la fausse confiance se cache rarement dans des exemples aussi caricaturaux, mais plutôt sous des formes plus « cultivées » : quand on a mocké tout autour, écrit verify sur une dizaine d'appels internes, et que le test commence à vérifier le scénario d'interactions, pas le comportement. C'est précisément dans ces formes « cultivées » que se trouve le vrai danger : le scénario semble solide, mais le comportement n'est toujours pas vérifié.

2. Over-mocking : on a mocké le monde — on a perdu le sens

L'over-mocking, c'est quand il y a tellement de mocks et de stubs que le test ne contient presque plus de comportement réel. On obtient un petit théâtre : on a prédéfini qui dit quoi, qui va où, qui salue qui de la main — et à la fin du spectacle tout le monde est content, car le scénario a été suivi. Sauf que la vraie vie, c'est-à-dire la production, n'a pas lu votre scénario.

Dans le projet ContentHub, la tentation de « tout mocker » apparaît particulièrement autour des services orchestrateurs. Par exemple, ArticleWorkflowService peut appeler ModerationClient, SlugService, la politique de transitions, le dépôt et l'expéditeur de notifications. Et le débutant pense : « Oh là là, il y a beaucoup de dépendances, donc il faut tout mocker, sinon le test ne sera pas un test unitaire ». Résultat — un test de 80 lignes dont 70 sont du when(...).

Voici l'odeur caractéristique de l'over-mocking : on voit dans le test du stubbing pour des données simples — titre, body, catégorie — du stubbing pour l'heure, pour l'utilisateur actuel, pour la génération de slug et encore une dizaine de configurations « au cas où », parce que « ça pourrait être appelé ».

import org.junit.jupiter.api.Test;

import static org.mockito.Mockito.*;

class OverMockingSmellTest {

    @Test
    void too_many_mocks_make_test_a_script() {
        // Suspect: même les données de l'article sont des mocks, au lieu d'un vrai objet
        ArticleData data = mock(ArticleData.class);
        when(data.getTitle()).thenReturn("Mockito 101");
        when(data.getBody()).thenReturn("text");

        // Mock du client externe — souvent justifié, mais combiné aux mocks de "données"
        // ça devient vite un théâtre de scénario
        ModerationClient moderation = mock(ModerationClient.class);
        when(moderation.moderate(anyString())).thenReturn(ModerationVerdict.OK);
    }
}

Oui, ce n'est encore que la moitié du test — et c'est là toute l'ironie. On n'a montré que le sommet de l'iceberg : déjà on voit des mocks de « données » (ce qui n'est généralement pas nécessaire) et « n'importe quel argument conviendra » (ce qui est généralement dangereux). Dans un grand test, on ajoutera encore une dizaine de verify, et on obtiendra un scénario vert qui survivra sans problème à une vraie casse de la logique métier.

Comment guérit-on en général de l'over-mocking ? Non par de la magie, mais en redonnant du sens. On revient à la carte des test doubles de la première leçon et on commence par la question la plus simple : « Peut-on laisser cette partie réelle ? ». Les simples value-objects et DTOs méritent presque toujours d'être créés vraiment. Une petite dépendance est parfois mieux remplacée par un fake que par du mocking avec stubbing pour simuler sa vie.

Comparez : au lieu de mocker des données simples, on peut créer un vrai objet — et le test devient immédiatement plus honnête et plus court.

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class LessMockingExampleTest {

    @Test
    void real_data_object_is_shorter_than_mocked_getters() {
        // Transparent: on voit directement les vraies données de l'article
        ArticleData data = new ArticleData("Mockito 101", "text");

        // On vérifie le vrai comportement d'un vrai objet, sans scénario pour les getters
        assertThat(data.getTitle()).isEqualTo("Mockito 101");
        assertThat(data.getBody()).isEqualTo("text");
    }
}

Cet exemple est très simple, mais il illustre bien le principe : le mock n'est pas le choix par défaut. Si un objet n'est qu'un « paquet de données », créez-le vraiment. Mockito est utile là où un vrai objet est inconvénient, lent, instable, ou là où le fait même de l'appel externe importe. Sinon, on obtient juste un théâtre de scénario.

Un court tableau pour s'orienter — pas une loi, mais un indice quand le test commence à ressembler à un scénario :

Ce qu'on mocke Comment ça apparaît dans le test Ce que ça signifie souvent
DTO / value objects beaucoup de when(getX()).thenReturn(...) on simule des données au lieu de les utiliser
« Tout en vrac » 6–10 mocks par test l'objet sous test est trop complexe, ou on ne teste pas la bonne chose
Des détails internes verify sur « chaque étape » overspecification (voir section suivante)

3. Overspecification : le test s'accroche à l'implémentation

L'overspecification, c'est quand un test est trop concret non pas sur le sens, mais sur l'implémentation. On fixe quelle méthode exacte de quelle dépendance a été appelée, combien de fois, dans quel ordre, avec quelles valeurs intermédiaires — alors que la règle métier n'en dépend pas. Résultat : les tests deviennent fragiles — le moindre refactoring qui conserve le comportement casse la moitié des tests, et l'équipe commence à détester les tests (et les tests lui rendent bien).

Un scénario fréquent d'overspecification dans ContentHub : on veut vérifier que la publication d'un article envoie une notification. C'est vraiment un effet externe — et verify est ici approprié. Mais ensuite la main se tend pour vérifier aussi que le service a appelé policy.canPublish, puis repo.findById, puis repo.save, puis notificationSender.send..., et aussi que « aucune autre méthode n'a été appelée ». Et le test se transforme en gardien qui vérifie non pas que la personne est venue au travail, mais le nombre de pas depuis le métro jusqu'au bureau.

Voici un exemple de test « trop scénarisé » via InOrder. Il a l'air solide, mais prouve souvent autre chose :

import org.junit.jupiter.api.Test;
import org.mockito.InOrder;

import static org.mockito.Mockito.*;

class OverSpecifiedOrderTest {

    @Test
    void checks_call_order_even_if_order_is_not_part_of_behavior() {
        // Mocks des dépendances — ok ici, on teste des interactions
        ArticleRepository repo = mock(ArticleRepository.class);
        PublicationNotificationSender sender = mock(PublicationNotificationSender.class);

        // L'objet testé utilise repo/sender
        ArticlePublisher publisher = new ArticlePublisher(repo, sender);
        publisher.publish(42L, "java-mockito");

        // Anti-pattern: on fixe l'ordre alors qu'il peut être un détail d'implémentation
        InOrder order = inOrder(repo, sender);
        order.verify(repo).save(any());
        order.verify(sender).sendPublished(eq(42L), anyString());
    }
}

L'ordre est parfois vraiment important. Par exemple, si on doit d'abord sauvegarder l'état, puis ensuite envoyer la notification, parce que la notification doit référencer les données sauvegardées. Mais dans la plupart des services métier, l'ordre des appels internes est un détail d'implémentation. Aujourd'hui on sauvegarde les données « avant l'envoi », demain on ajoute de la mise en buffer — le comportement reste le même, mais le test échoue parce qu'on « a agité les bras dans le mauvais ordre ».

La variante plus saine — vérifier ce qui constitue vraiment le contrat de comportement : que la sauvegarde a eu lieu et que la notification a été envoyée. Et souvent même sans mentionner l'ordre.

import org.junit.jupiter.api.Test;

import static org.mockito.Mockito.*;

class LessBrittleInteractionTest {

    @Test
    void verifies_meaningful_effects_without_order_drama() {
        // Mocks des frontières: dépôt et envoi de notifications — effets externes
        ArticleRepository repo = mock(ArticleRepository.class);
        PublicationNotificationSender sender = mock(PublicationNotificationSender.class);

        // On lance l'action
        new ArticlePublisher(repo, sender).publish(42L, "java-mockito");

        // On vérifie uniquement les effets significatifs, sans détailler la « chorégraphie » interne
        verify(repo).save(any());
        verify(sender).sendPublished(eq(42L), anyString());
    }
}

Une autre source populaire d'overspecification — le désir de « prouver que rien d'autre ne s'est passé » via verifyNoMoreInteractions(...). Sur le papier, ça ressemble à de la « propreté », en pratique ça se transforme souvent en un test qui casse dès qu'on ajoute un logging inoffensif, une métrique ou une vérification supplémentaire. Ce comportement est parfois approprié sur des frontières particulièrement sensibles — par exemple, « en cas d'erreur de modération, on ne doit absolument pas envoyer de notification » — mais en tant que comportement par défaut de tous les tests, c'est un moyen rapide de faire pousser un système de tests fragile.

4. Matchers larges et stubbing « pour tout »

Dans la leçon précédente sur les matchers, on a dit : anyString() et ses amis sont un outil pratique, mais dangereux. Ce danger se manifeste ici dans sa forme la plus désagréable : le test reste vert même quand les arguments sont mauvais. Autrement dit, on vérifie le fait de l'appel, mais pas le sens de l'appel. Et dans ContentHub, le sens de l'argument compte souvent plus que le fait lui-même.

Imaginez : ArticleWorkflowService envoie le texte de l'article à ModerationClient. Si le service envoie accidentellement une chaîne vide, la modération est formellement « appelée », mais le sens métier est détruit. Et si dans le test on a when(moderation.moderate(anyString())).thenReturn(OK), on a soi-même autorisé le service à envoyer n'importe quoi — du texte, le mot "chaton", ou le vide.

Un bug « doux » très typique : on écrit anyString() « provisoirement », pour ne pas avoir à démêler comment le payload est exactement construit, et puis ce « provisoirement » devient éternel. Et ensuite tout défaut dans la construction du payload passe inaperçu.

import org.junit.jupiter.api.Test;

import static org.mockito.Mockito.*;

class TooWideMatchersTest {

    @Test
    void anyString_stubbing_can_hide_wrong_payload() {
        // Préparation: mock du client externe
        ModerationClient moderation = mock(ModerationClient.class);

        // Dangereux: on autorise "n'importe quel texte", donc un mauvais payload ne sera pas détecté
        when(moderation.moderate(anyString())).thenReturn(ModerationVerdict.OK);

        // Même une chaîne vide passe — le test ne protège pas le sens métier
        moderation.moderate("");

        // Et la vérification est trop faible: "on a appelé quelque chose" — et ça suffit
        verify(moderation).moderate(anyString());
    }
}

Que faire à la place ? Si la chaîne exacte importe — utiliser eq(...). Si la chaîne est construite à l'intérieur du service et que la correspondance exacte est inconvéniente, prendre ArgumentCaptor et vérifier les propriétés sémantiques : que la chaîne n'est pas vide, qu'elle contient le titre, qu'elle contient le body, qu'elle ne dépasse pas la limite, etc. Autrement dit, vérifier ce qui protège vraiment le comportement.

import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;

class CaptorForMeaningTest {

    @Test
    void captures_payload_and_checks_meaning_not_exact_format() {
        // Mock de la frontière: on s'intéresse à ce qui a été envoyé en modération
        ModerationClient moderation = mock(ModerationClient.class);

        // Appel (dans un vrai test, ce serait fait par le service sous test)
        moderation.moderate("""
                Title
                Body
                """);

        // On capture l'argument réel de l'appel pour en vérifier le sens
        ArgumentCaptor<String> textCaptor = ArgumentCaptor.forClass(String.class);
        verify(moderation).moderate(textCaptor.capture());

        // Vérification minimalement suffisante: le titre doit absolument être présent
        assertThat(textCaptor.getValue()).contains("Title");
    }
}

Il est important de trouver l'équilibre. Une vérification de format trop stricte peut aussi devenir fragile — par exemple, si le séparateur de lignes change. Une vérification trop laxiste (anyString()) transforme le test en poste de contrôle sans surveillance. L'idée est de choisir la « rigueur minimalement suffisante » : juste assez stricte pour qu'un défaut ne passe pas, et pas plus.

5. Mocker les DTO et les entités

Il y a une forme particulière d'over-mocking qui semble « anodine » : mocker des objets de données. Par exemple, on mocke ArticleData, CreateArticleRequest, Article, Category — et on commence à configurer les champs avec du stubbing : when(getTitle()).thenReturn(...), when(getBody()).thenReturn(...), etc.

Pourquoi est-ce particulièrement mauvais dans les tests ContentHub ? Parce qu'on perd deux choses : la lisibilité et l'honnêteté. La lisibilité disparaît parce qu'au lieu de « voici les données de l'article », on voit une liste de when. L'honnêteté — parce que le mock retourne null / 0 / false par défaut, et on peut facilement ne pas remarquer qu'une partie des données n'a pas été préparée. Le test deviendra accidentellement vert ou accidentellement rouge, mais certainement pas prévisible.

Comparons les deux approches.

Mauvais (on mocke les données, on écrit un scénario pour les getters) :

import org.junit.jupiter.api.Test;

import static org.mockito.Mockito.*;

class MockingDataIsNoiseTest {

    @Test
    void mocked_data_object_creates_setup_noise() {
        // Anti-pattern: data n'est qu'un conteneur de valeurs, mais on en fait un mock
        ArticleData data = mock(ArticleData.class);

        // Résultat: le test « bruisse » de configurations au lieu de montrer les données
        when(data.getTitle()).thenReturn("Mockito");
        when(data.getBody()).thenReturn("Basics");

        // ensuite le code testé lit data.getTitle()/getBody()
    }
}

Bien (vrai objet de données, les valeurs sont visibles immédiatement) :

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class RealDataObjectTest {

    @Test
    void real_data_object_is_clear_and_honest() {
        // Honnête: on crée des vraies données sans mocks ni stubbing
        ArticleData data = new ArticleData("Mockito", "Basics");

        // Les données sont visibles à l'oeil, pas cachées dans when(...).thenReturn(...)
        assertThat(data.getTitle()).isEqualTo("Mockito");
        assertThat(data.getBody()).isEqualTo("Basics");
    }
}

Dans un vrai test unitaire, on testera bien sûr non pas ArticleData, mais le service qui l'utilise. Mais le principe est le même : les données méritent de rester des données, pas de se transformer en ensemble de stubs. Ironiquement, l'un des meilleurs moyens « doux » de protéger l'équipe contre le mocking des données — utiliser des structures immuables (par exemple des record) et ne pas laisser entrer dans le projet l'habitude de « tout mocker ». Mais sans record non plus, cette discipline fonctionne : si un objet ne fait pas d'I/O et ne contient pas de logique complexe, il est généralement plus simple à créer qu'à mocker.

6. Le « verify-holisme » : le spectacle au lieu de la vérification

Les vérifications d'interactions sont un outil très puissant, et c'est pourquoi il est facile d'en abuser. Un débutant a souvent l'impression que verify est « la vraie vérification », et que assertThat(result) est secondaire. En pratique c'est souvent l'inverse : vérifier le résultat ou l'état donne généralement une garantie de comportement plus solide, et verify est utile ponctuellement — quand l'effet externe est précisément l'objectif.

Dans ContentHub, un bon exemple de verify approprié — l'envoi de la notification de publication. Si le service publie un article et doit appeler PublicationNotificationSender, alors verify(sender).sendPublished(...) est la preuve directe d'un effet important. De même, si ArticleWorkflowService doit appeler ModerationClient lors d'une demande de revue, le fait de cet appel fait aussi souvent partie du comportement.

Un mauvais exemple de verify — vérifier les étapes internes d'un algorithme qui ne font pas partie du contrat. Supposons que SlugService fasse en interne trim(), toLowerCase(), replaceAll(). Si on commence à vérifier combien de fois replaceAll a été appelé ou quel regex a été utilisé — via spy et partial mock — on fixe le test à l'implémentation avec des clous. Et tout refactoring normal deviendra une « régression de tests », même si le résultat métier est resté le même.

Une technique psychologique aide beaucoup : imaginez que demain on change l'implémentation, mais pas le comportement. Le test doit-il échouer ? Si oui — super. Si non — on vérifie trop. verify est nécessaire quand le changement d'interaction est le changement de comportement — par exemple, la notification a cessé d'être envoyée.

Encore un point important : les vérifications négatives (never(), times(0)) sont utiles, mais pas comme élément décoratif. Elles sont puissantes là où « ne pas faire » fait partie de la règle. Par exemple : « si la modération a retourné BLOCK, ne pas envoyer de notification de publication ». Alors verify(sender, never()).sendPublished(...) sera sensé. Mais si on écrit never() juste parce qu'on « veut tout couvrir », le test devient artificiel et fragile.

Comment garder les tests Mockito utiles

Si tout ce qui a été dit ci-dessus se résume en une seule pensée pratique, on obtient une règle peu à la mode, mais efficace : Mockito n'est pas un but ni un style de vie, c'est un outil d'isolation ponctuelle. Commencez par les vrais objets et ajoutez du mocking uniquement là où sans lui le test devient coûteux, non déterministe ou dépendant du monde extérieur. Et quand on utilise déjà un mock, posez-vous la question : « Est-ce que je vérifie le résultat ? Ou est-ce que je vérifie le scénario d'appels ? Si c'est le scénario — est-il vraiment important ? ».

Il est utile de garder en tête une petite « carte de choix » — non pas comme une religion, mais comme un rappel que le mock n'est pas l'option par défaut :

flowchart TD
    A[Il y a une dépendance dans la classe testée] --> B{Peut-on garder l'objet réel ?}
    B -->|Oui| C[On garde le real object]
    B -->|Non| D{Peut-on faire un simple Fake ?}
    D -->|Oui| E["On utilise Fake (in-memory, prévisible)"]
    D -->|Non| F{Seule une réponse connue à l'avance est nécessaire ?}
    F -->|Oui| G[Stub: stubbing sans verify]
    F -->|Non| H[Mock + verify uniquement pour l'effet externe significatif]

Ce schéma fonctionne particulièrement bien dans notre contexte ContentHub, où il y a à la fois des « frontières techniques » — par exemple, la modération externe et l'envoi de notifications — et beaucoup d'informations domaine simples : articles, statuts, catégories — qu'on n'a aucune envie de transformer en mocks. Plus il y a de vraies données dans le test, plus le scénario est simple à lire. Moins il y a de verify sur les étapes internes, moins le refactoring coûte cher.

7. Erreurs classiques

Avant de fermer le sujet, il est utile de passer en revue quelques erreurs qui mènent le plus souvent à ces fameux « tests verts sans signification ». Elles semblent banales, mais c'est justement de ces petites choses qu'est généralement fait un test suite fragile.

Erreur n°1 : mocker la classe testée elle-même.
C'est le chemin le plus direct pour obtenir un test qui vérifie Mockito plutôt que la logique métier. Si on écrit mock(ArticleWorkflowService.class) et qu'on vérifie ensuite que le mock a retourné "OK", on ne teste pas ContentHub. On vérifie que Mockito n'a pas oublié nos configurations.

Erreur n°2 : mocker des objets-données et configurer les getters avec du stubbing.
Quand on mocke DTO/Entity/Value Object et qu'on commence à écrire when(getTitle()).thenReturn(...), le test cesse d'être un scénario lisible. De plus, on oublie facilement des champs : le mock retourne null, et on ne sait plus si le test est rouge à cause d'un vrai défaut ou à cause de données oubliées.

Erreur n°3 : utiliser any() presque partout et se féliciter que « le test n'est pas fragile ».
Oui, le test devient « pas fragile », mais souvent pour une raison très simple : il ne vérifie rien du tout. Si on écrit when(moderation.moderate(anyString())).thenReturn(OK), le service peut envoyer une chaîne vide, un mauvais format ou carrément le mauvais texte — et le test restera vert.

Erreur n°4 : vérifier l'ordre des appels sans vraie nécessité.
InOrder peut être utile, mais seulement quand l'ordre fait partie du comportement. Si l'ordre est un détail d'implémentation, le test se retrouve lié au chemin d'exécution. Tout refactoring le casse, même si le résultat métier est resté le même.

Erreur n°5 : transformer verifyNoMoreInteractions(...) en rituel obligatoire.
Dans de rares cas c'est approprié — par exemple, quand il faut prouver : « ne pas envoyer de notification en cas d'erreur ». Mais si on ajoute verifyNoMoreInteractions « pour la discipline », on commence à punir le code pour toute amélioration inoffensive : une vérification supplémentaire, une métrique, un log. Au final, les gens commencent à contourner les tests au lieu d'améliorer le code.

1
Mission
Spring Test, niveau 4, leçon 4
Bloqué
Objet de données réel au lieu d'un mock
Objet de données réel au lieu d'un mock
1
Mission
Spring Test, niveau 4, leçon 4
Bloqué
Uniquement les interactions significatives
Uniquement les interactions significatives
1
Étude/Quiz
Doublures de test, niveau 4, leçon 4
Indisponible
Doublures de test
Mock, stub, fake, spy
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION