Tests et qualité logicielle

Tests unitaires en Java avec JUnit 5 et Mockito 5

Débutant8 min de lectureMis à jour

Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise

Un tour d'horizon pratique de l'outillage de test unitaire Java tel qu'il se présente aujourd'hui : le modèle de programmation JUnit Jupiter, les assertions fluides d'AssertJ, les cinq types de doublures de test et la façon dont Mockito les implémente. Il se termine par les arbitrages les plus délicats : tester le temps, tester les exceptions et décider de ce qu'il ne faut jamais mocker.

Ce que vous allez apprendre

  • Structurer une classe de test Jupiter avec les bonnes annotations de cycle de vie et un nom qui énonce le comportement
  • Écrire ses assertions avec assertAll, assertThrows et l'API fluide d'AssertJ plutôt qu'avec une pile d'appels à assertEquals
  • Remplacer les tests répétitifs par @ParameterizedTest et organiser les cas avec @Nested
  • Distinguer dummy, stub, spy, mock et fake, et choisir le moins coûteux qui fasse l'affaire
  • Créer des stubs, vérifier des appels et capturer des arguments avec Mockito 5 sans surspécifier l'implémentation

Un test unitaire est une vérification petite, rapide et déterministe d'un comportement précis, isolée de tout ce qui serait assez lent pour qu'on le remarque. C'est le seul type de test qu'un développeur accepte volontiers de lancer des dizaines de fois par heure, et c'est ce qui en fait le socle de tout le reste : un refactoring est sûr en proportion de la vitesse à laquelle vous découvrez que vous avez cassé quelque chose.

L'outillage Java en la matière est stable depuis des années (JUnit Jupiter pour la structure, AssertJ pour les assertions, Mockito pour les doublures de test), mais les détails ont évolué. JUnit 5.x et la lignée JUnit 6 partagent le modèle de programmation Jupiter sur Java 17 et au-delà ; Mockito 5 utilise par défaut l'inline mock maker, si bien que mocker des classes final et des méthodes statiques ne demande plus de dépendance supplémentaire. Les exemples qui suivent supposent ces versions et un build qui place tout sous src/test/java.

Ce qui fait d'un test un test unitaire

Deux propriétés comptent plus que n'importe quelle définition de l'« unité ». Un test unitaire doit être rapide : une suite d'un millier de tests doit se terminer en quelques secondes, donc rien ne doit toucher le réseau, une vraie base de données ou l'horloge d'une manière qui le fasse attendre. Et il doit être isolé : l'exécuter seul, dans un autre ordre ou en parallèle de ses voisins doit donner le même résultat. Quand ce n'est pas le cas, les trois coupables habituels sont un état statique partagé, des fichiers écrits à un chemin fixe et une base de données laissée sale par le test précédent.

Tout le reste se discute. Un test qui construit trois value objects collaborant entre eux reste un test unitaire dans tout sens utile du terme. Un test qui mocke les six dépendances d'un service au point de ne plus vérifier que ses propres stubs n'est pas un bon test, quoi qu'en dise la classification.

Le modèle de programmation Jupiter

class InvoiceTotalTest {

    private Invoice invoice;

    @BeforeEach
    void newInvoice() {
        invoice = new Invoice("INV-1", Currency.getInstance("EUR"));
    }

    @Test
    @DisplayName("an empty invoice totals zero in its own currency")
    void emptyInvoiceTotalsZero() {
        assertThat(invoice.total()).isEqualTo(Money.zero("EUR"));
    }

    @Test
    void lineTotalsAreSummed() {
        invoice.add(new Line("Consulting", 3, Money.of("450.00", "EUR")));
        invoice.add(new Line("Licence",    1, Money.of("120.00", "EUR")));

        assertThat(invoice.total()).isEqualTo(Money.of("1470.00", "EUR"));
    }
}

Avec Jupiter, les classes et méthodes de test n'ont pas besoin du modificateur public, et il n'y a pas de runner : le moteur découvre directement les méthodes @Test. @BeforeEach et @AfterEach s'exécutent autour de chaque test ; @BeforeAll et @AfterAll s'exécutent une fois par classe et doivent être static, sauf si la classe est annotée @TestInstance(Lifecycle.PER_CLASS). Par défaut, une nouvelle instance de la classe de test est créée pour chaque méthode de test, et c'est ce qui empêche un test de laisser fuir son état dans le suivant : résistez à la tentation de changer cela par commodité.

@DisplayName fournit un libellé lisible pour les rapports ; @Disabled("reason") saute un test et, contrairement à une mise en commentaire, laisse une trace visible. @Tag("slow") regroupe des tests pour que le build puisse les inclure ou les exclure : c'est ainsi qu'une même arborescence de sources peut servir à la fois une exécution rapide avant commit et une exécution plus complète dans le pipeline.

Des assertions qui se lisent

Les assertions de Jupiter sont suffisantes et toujours disponibles. Trois d'entre elles méritent d'être connues, même dans une base de code qui utilise AssertJ :

assertAll("customer",
        () -> assertEquals("Ada", customer.firstName()),
        () -> assertEquals("Lovelace", customer.lastName()),
        () -> assertTrue(customer.isActive()));

IllegalStateException thrown = assertThrows(IllegalStateException.class,
        () -> account.withdraw(Money.of("500.00", "EUR")));
assertEquals("INSUFFICIENT_FUNDS", thrown.getErrorCode());

assertTimeoutPreemptively(Duration.ofMillis(200), () -> report.render());

assertAll signale tous les échecs qu'il contient au lieu de s'arrêter au premier, ce qui transforme trois relances en une seule. assertThrows renvoie l'exception interceptée pour que vous puissiez vérifier son contenu : préférez toujours un code d'erreur typé ou un champ à une comparaison sur le texte du message, car les messages sont reformulés et traduits.

AssertJ remplace le jeu de devinettes sur le assertX à appeler par un unique assertThat et l'autocomplétion :

assertThat(orders)
        .hasSize(3)
        .extracting(Order::status)
        .containsExactly(Status.NEW, Status.PAID, Status.SHIPPED);

assertThat(findCustomer("unknown")).isEmpty();          // Optional

assertThat(response.body())
        .asString()
        .contains("\"currency\":\"EUR\"");

assertThatThrownBy(() -> parser.parse("2026-13-45"))
        .isInstanceOf(DateTimeParseException.class)
        .hasMessageContaining("13");

assertThat(actualOrder)
        .usingRecursiveComparison()
        .ignoringFields("id", "createdAt")
        .isEqualTo(expectedOrder);

usingRecursiveComparison répond au problème fréquent de la comparaison d'objets dont le equals n'est pas fiable, et c'est bien mieux que d'écrire une douzaine d'assertions champ par champ qui finiront par ne plus être à jour. Lorsqu'un type du domaine revient souvent dans les assertions, une classe d'assertion personnalisée qui étend AbstractAssert donne à la suite son propre vocabulaire : assertThat(invoice).isPaid().hasTotalOf("1470.00").

Éliminer la répétition : tests paramétrés et imbriqués

Lorsqu'un même comportement doit être vérifié avec plusieurs entrées, un test paramétré le dit explicitement au lieu de cacher les cas dans une boucle :

@ParameterizedTest(name = "IBAN {0} is {1}")
@CsvSource({
        "FR7630006000011234567890189, true",
        "DE89370400440532013000,      true",
        "FR7630006000011234567890188, false",   // wrong check digits
        "XX00,                        false"
})
void validatesIbanCheckDigits(String iban, boolean expected) {
    assertThat(Iban.isValid(iban)).isEqualTo(expected);
}

@ParameterizedTest
@EnumSource(value = Status.class, names = {"SHIPPED", "DELIVERED", "REFUNDED"})
void terminalStatusesCannotBeCancelled(Status status) {
    assertThatThrownBy(() -> new Order(status).cancel())
            .isInstanceOf(IllegalStateException.class);
}

@ParameterizedTest
@MethodSource("discountScenarios")
void appliesTieredDiscount(Money subtotal, Tier tier, Money expected) {
    assertThat(pricing.discount(subtotal, tier)).isEqualTo(expected);
}

static Stream<Arguments> discountScenarios() {
    return Stream.of(
            arguments(Money.of("99.99", "EUR"),  Tier.STANDARD, Money.zero("EUR")),
            arguments(Money.of("100.00", "EUR"), Tier.STANDARD, Money.of("5.00", "EUR")),
            arguments(Money.of("100.00", "EUR"), Tier.PREMIUM,  Money.of("10.00", "EUR")));
}

Chaque jeu d'arguments est rapporté comme un test distinct, si bien qu'un échec nomme l'entrée qui a posé problème. L'attribut name contrôle la façon dont il s'affiche. Parmi les autres sources, @ValueSource sert pour un unique paramètre primitif, @NullAndEmptySource pour les deux entrées que tout le monde oublie, et @CsvFileSource quand le tableau dépasse ce qui a sa place dans une annotation. Les valeurs limites issues des techniques de conception de tests s'y prêtent naturellement : une ligne par limite, avec un commentaire qui précise de quelle limite il s'agit.

Les classes internes @Nested regroupent les tests qui partagent une même mise en place et donnent au rapport une hiérarchie lisible :

class ShoppingCartTest {

    @Nested
    class WhenEmpty {
        private final Cart cart = new Cart();

        @Test void totalIsZero()          { assertThat(cart.total()).isZero(); }
        @Test void checkoutIsRejected()   { assertThatThrownBy(cart::checkout).isInstanceOf(EmptyCartException.class); }
    }

    @Nested
    class WithOneItem {
        private final Cart cart = new Cart().add(Item.of("SKU-1", Money.of("20.00", "EUR")));

        @Test void totalIsTheItemPrice()  { assertThat(cart.total()).isEqualTo(Money.of("20.00", "EUR")); }
        @Test void removingLeavesEmpty()  { assertThat(cart.remove("SKU-1").isEmpty()).isTrue(); }
    }
}

Cinq types de doublures de test

Dans le langage courant, on dit « mock » pour toutes, mais les distinctions correspondent à de vrais compromis.

DoublureCe qu'elle faitUsage typique
DummyPassée pour satisfaire une signature, jamais utiliséeRemplir un paramètre de constructeur inutilisé
StubRenvoie des réponses préparées à l'avanceFournir au code testé les données dont un scénario a besoin
SpyObjet réel qui enregistre la façon dont il a été appeléCode legacy difficile à remplacer
MockProgrammé avec des attentes, vérifié après coupVérifier qu'un effet de bord a bien eu lieu
FakeImplémentation fonctionnelle mais simplifiéeRepository en mémoire, hash map à la place d'un serveur de cache

Celui que l'on sous-utilise, c'est le fake. Une implémentation en mémoire d'une interface de repository, écrite une fois pour toutes, permet à des dizaines de tests de se lire comme du code ordinaire, sans le moindre stub, et elle détecte des bugs de séquencement qu'un stub renvoyant une valeur fixe ne peut pas voir. Si l'interface d'un collaborateur est petite et que vous la stubbez sans cesse de la même manière, écrivez le fake.

Mockito en pratique

@ExtendWith(MockitoExtension.class)
class SubscriptionServiceTest {

    @Mock  PaymentGateway gateway;
    @Mock  SubscriberRepository subscribers;
    @Mock  Mailer mailer;

    @InjectMocks SubscriptionService service;

    @Test
    void chargesAndActivatesOnSuccess() {
        Subscriber ada = new Subscriber(7L, "[email protected]", Plan.ANNUAL);
        when(subscribers.findById(7L)).thenReturn(Optional.of(ada));
        when(gateway.charge(any(ChargeRequest.class)))
                .thenReturn(ChargeResult.approved("txn-88"));

        service.renew(7L);

        assertThat(ada.status()).isEqualTo(Status.ACTIVE);
        verify(subscribers).save(ada);
        verify(mailer).sendRenewalReceipt("[email protected]", "txn-88");
    }

    @Test
    void doesNotActivateWhenTheCardIsDeclined() {
        when(subscribers.findById(7L)).thenReturn(Optional.of(new Subscriber(7L, "[email protected]", Plan.ANNUAL)));
        when(gateway.charge(any())).thenReturn(ChargeResult.declined("insufficient_funds"));

        assertThatThrownBy(() -> service.renew(7L)).isInstanceOf(PaymentDeclinedException.class);

        verify(subscribers, never()).save(any());
        verifyNoInteractions(mailer);
    }
}

MockitoExtension crée les mocks, les injecte et, point important, impose les strict stubs : un stub jamais utilisé fait échouer le test. Cette règle détecte les mises en place copiées-collées et les tests qui, sans bruit, ont cessé de correspondre au code. Lorsqu'un stub est réellement conditionnel, enveloppez-le dans lenient() au lieu d'assouplir toute la classe.

Les argument matchers (any(), eq(), argThat()) doivent être utilisés pour tous les arguments d'un appel ou pour aucun : mélanger une valeur brute et un matcher lève une exception. Lorsque vous devez examiner ce qui a été passé, capturez-le :

@Captor ArgumentCaptor<ChargeRequest> charge;

@Test
void chargesTheAnnualPriceInTheSubscriberCurrency() {
    // ... arrange and act ...
    verify(gateway).charge(charge.capture());

    assertThat(charge.getValue().amount()).isEqualTo(Money.of("199.00", "EUR"));
    assertThat(charge.getValue().idempotencyKey()).isNotBlank();
}

Quelques autres points à connaître. thenAnswer calcule un résultat à partir des arguments (when(repo.save(any())).thenAnswer(i -> i.getArgument(0)) est la formule classique pour « save renvoie ce qu'on lui a donné ») ; doThrow(...).when(mock).method() permet de stubber les méthodes void ; verify(mock, times(2)), atLeastOnce() et InOrder contraignent le nombre d'appels et leur ordre ; enfin, mockStatic et mockConstruction existent pour le code legacy, mais doivent être pris comme le signal que la conception a besoin d'un point de découplage, pas comme un outil de tous les jours.

Les dépendances encombrantes

Le temps. Injectez une java.time.Clock. La production fournit Clock.systemUTC(), le test fournit Clock.fixed(Instant.parse("2026-03-01T09:00:00Z"), ZoneOffset.UTC), et les assertions sur l'expiration, la rétention ou les jours ouvrés deviennent exactes au lieu d'approximatives.

L'aléatoire et les identifiants. Prenez en dépendance un Supplier<UUID> ou un RandomGenerator initialisé avec une graine. Un test qui vérifie qu'une clé d'idempotence « n'est pas vide » est bien plus faible qu'un test qui vérifie qu'elle vaut une valeur connue.

Les fichiers. Le paramètre ou champ @TempDir de Jupiter donne à chaque test un répertoire neuf, supprimé ensuite, ce qui est plus simple et plus sûr qu'un chemin fixe sous /tmp.

L'asynchronisme. Jamais de Thread.sleep. Soit le composant accepte un executor, afin que le test puisse exécuter les tâches de façon synchrone, soit vous interrogez l'état à intervalles réguliers avec Awaitility : await().atMost(TWO_SECONDS).untilAsserted(() -> assertThat(store.count()).isEqualTo(3)).

Ce qu'il ne faut pas mocker

Ne mockez pas les types dont vous n'êtes pas propriétaire : un driver JDBC, une bibliothèque cliente HTTP, un client de message broker. Leurs contrats sont complexes, votre stub encode ce que vous croyez savoir d'eux, et le test passe ensuite, que ces croyances soient vraies ou non. Placez devant la bibliothèque une fine interface qui vous appartient et mockez celle-ci, ou testez contre le vrai composant dans un test d'intégration.

Ne mockez pas les value objects, les DTO, les collections ni les enums. En construire un vrai coûte moins cher qu'un stub et se lit infiniment mieux.

Ne mockez pas la classe testée, y compris au moyen d'un spy partiel qui stubbe la moitié de ses propres méthodes. Si une méthode est difficile à tester parce qu'une autre méthode de la même classe fait quelque chose de coûteux, cette autre méthode demande à devenir un collaborateur à part entière.

Enfin, ne vérifiez pas chaque appel. Chaque verify est une assertion sur la manière dont le code fonctionne, et une suite qui en regorge échoue sur des refactorings qui n'ont rien changé d'observable. Vérifiez les effets de bord qui sont la raison d'être de la méthode (le paiement débité, le message envoyé) et laissez les valeurs de retour porter le reste. Le nommage aide aussi : renewalReceiptIsSentAfterASuccessfulCharge énonce un comportement, alors que testRenew2 n'énonce rien du tout. Le guide des conventions de code couvre les règles qui gardent le code de test aussi lisible que le code de production.

Points clés à retenir

  • Un test unitaire mérite son nom parce qu'il est rapide et isolé ; tout le reste de la classification relève de la querelle de vocabulaire.
  • Jupiter crée une nouvelle instance de test par méthode, ne demande ni runner ni modificateur public, et fournit d'emblée assertAll, assertThrows et @TempDir.
  • Les assertions fluides d'AssertJ produisent de meilleurs messages d'échec et gèrent collections, optionals et comparaison d'objets complets sans code utilitaire.
  • @ParameterizedTest avec @CsvSource ou @MethodSource transforme un tableau de cas limites en tests rapportés individuellement.
  • Connaissez les cinq doublures de test et optez pour un fake lorsque le même stub revient sans cesse ; les strict stubs vous signaleront quand un mock s'est éloigné de la réalité.
  • Injectez horloges, sources d'aléa et executors plutôt que de mocker des méthodes statiques, et ne mockez jamais les types dont vous n'êtes pas propriétaire ni la classe testée.

Sources et lectures complémentaires

Questions fréquentes

Faut-il utiliser les assertions intégrées de JUnit ou AssertJ ?
Les deux fonctionnent, mais AssertJ passe mieux à l'échelle. Son API fluide permet de trouver la bonne assertion par l'autocomplétion, produit des messages d'échec qui décrivent la différence au lieu de déverser deux objets, et gère collections, optionals, exceptions et comparaison récursive sans méthodes utilitaires. Les assertions de JUnit conviennent pour une égalité simple et sont toujours disponibles ; la plupart des équipes standardisent sur AssertJ et utilisent en complément assertThrows ou assertAll de Jupiter.
Quand utiliser @Mock plutôt que construire l'objet réel ?
Mockez un collaborateur lorsqu'il est lent, non déterministe, qu'il a des effets de bord indésirables, ou lorsque l'interaction avec lui est précisément l'objet du test. Utilisez l'objet réel lorsqu'il s'agit d'une valeur, d'une structure de données simple ou d'une fonction pure : les mocker affaiblit le test et le couple à un détail d'implémentation. Si mocker quelque chose demande plus de trois appels stubbés, c'est généralement le signe que la conception compte trop de collaborateurs.
Pourquoi Mockito se plaint-il de stubs inutiles ?
MockitoExtension active par défaut les strict stubs, qui font échouer un test définissant un stub pour un appel que le code testé n'effectue jamais. C'est une vérification réellement utile : un stub inutilisé signifie en général que le test ne correspond plus au code de production, ou que le stub a été copié depuis un autre test. Corrigez le test plutôt que de passer en mode lenient ; si un stub est légitimement conditionnel, marquez uniquement celui-là avec Mockito.lenient().
Comment tester unitairement du code qui appelle LocalDateTime.now() ?
Injectez une java.time.Clock et lisez l'heure à travers elle. Le code de production reçoit Clock.systemDefaultZone() depuis la configuration, les tests reçoivent Clock.fixed(instant, zone) et deviennent déterministes. Le même principe vaut pour les nombres aléatoires, la génération d'UUID et le système de fichiers : prenez la source de non-déterminisme en paramètre du constructeur au lieu d'appeler une fabrique statique, et le test n'a besoin d'aucune astuce sur le bytecode.