Tests unitaires : JUnit, AssertJ et Mockito

Publié le · Mis à jour le

L'idée en une phrase

Un test unitaire vérifie une seule classe, isolée du reste : sans Spring, sans base de données, sans réseau. Il s'exécute en quelques millisecondes et dit précisément ce qui ne marche pas. C'est le filet de sécurité qui te permet de modifier du code sans craindre de casser quelque chose ailleurs.

Une image pour comprendre : le banc d'essai

Dans une usine automobile, on ne teste pas les freins en faisant rouler la voiture terminée. Chaque pièce passe d'abord sur un banc d'essai : on fixe le frein seul, on simule une pression sur la pédale, et on mesure s'il freine comme prévu.

  • Rapide : quelques secondes, pas besoin d'assembler la voiture.
  • Précis : si le test échoue, on sait que c'est le frein, pas le moteur ni les pneus.
  • Simulé : la pédale est remplacée par un vérin qui appuie exactement comme on veut.

Un test unitaire, c'est ce banc d'essai : ta classe est la pièce, et ce dont elle dépend (un repository, une API externe) est simulé.

Plus tard, on testera aussi des morceaux assemblés (les tests d'intégration), puis la voiture entière sur la route (les tests de bout en bout). Mais tout commence par le banc d'essai.

Type de test Ce qu'on teste Vitesse Proportion
unitaire une classe seule millisecondes la grande majorité
intégration plusieurs couches ensemble (avec Spring, une base…) secondes une partie
bout en bout l'application entière, comme un utilisateur longues secondes, minutes quelques-uns

Les outils

Le starter spring-boot-starter-test, présent dans tout projet Spring Boot, fournit déjà tout :

Outil Rôle
JUnit lance les tests et dit lesquels réussissent ou échouent
AssertJ écrit les vérifications de façon lisible : assertThat(total).isEqualTo(...)
Mockito crée des faux objets (mocks) pour simuler les dépendances

Note : Spring Boot 3 utilise JUnit 5, Spring Boot 4 utilise JUnit 6. Les deux partagent la même API (org.junit.jupiter.api) : tout ce qui suit s'applique aux deux. ⚠️ En revanche, une annotation @Test importée depuis org.junit (sans jupiter) vient du très ancien JUnit 4 : vérifie l'import proposé par ton IDE.

Un premier test

Prenons une règle simple, placée dans l'entité Commande (voir la fiche sur la validation métier) :

public class Commande {

    private Statut statut;

    public void annuler() {
        if (statut == Statut.EXPEDIEE) {
            throw new CommandeNonAnnulableException(id, statut);
        }
        statut = Statut.ANNULEE;
    }
}

Le test, dans src/test/java, dans le même package que la classe testée :

class CommandeTest {

    @Test
    void une_commande_en_cours_peut_etre_annulee() {
        // Étant donné
        Commande commande = commandeAvecStatut(Statut.EN_COURS);

        // Quand
        commande.annuler();

        // Alors
        assertThat(commande.getStatut()).isEqualTo(Statut.ANNULEE);
    }

    @Test
    void une_commande_expediee_ne_peut_pas_etre_annulee() {
        Commande commande = commandeAvecStatut(Statut.EXPEDIEE);

        assertThatThrownBy(commande::annuler)
                .isInstanceOf(CommandeNonAnnulableException.class);
    }
}

Aucun Spring, aucune base de données : ces tests s'exécutent en quelques millisecondes.

La structure : Étant donné / Quand / Alors

Chaque test suit les mêmes trois étapes (en anglais : Given / When / Then, ou Arrange / Act / Assert) :

Étape Ce qu'on y fait
Étant donné on prépare la situation de départ
Quand on exécute une seule action : celle qu'on teste
Alors on vérifie le résultat

Si le « Quand » contient plusieurs actions, le test vérifie probablement plusieurs choses : découpe-le.

Le nom du test

Le nom doit dire quel comportement est vérifié. Quand un test échoue dans le pipeline, son nom est souvent la seule chose qu'on lit :

void test1()                                                  // ❌ ne dit rien
void testAnnuler()                                            // ❌ annuler quoi, dans quel cas ?
void une_commande_expediee_ne_peut_pas_etre_annulee()         // ✅ se lit comme une règle

Les tirets bas rendent les noms longs lisibles. Certaines équipes préfèrent @DisplayName("Une commande expédiée ne peut pas être annulée") : suis la convention du projet.

Écrire de bonnes vérifications avec AssertJ

AssertJ permet d'écrire des vérifications qui se lisent comme une phrase, avec des messages d'échec clairs :

assertThat(total).isEqualTo(42);
assertThat(nom).isEqualTo("Alice").startsWith("Al");
assertThat(commandes).hasSize(3);
assertThat(commandes).isEmpty();
assertThat(clients).extracting(Client::getNom).containsExactly("Alice", "Bruno");
assertThat(optional).isPresent();
assertThatThrownBy(() -> service.commander(1L, dto))
        .isInstanceOf(PlafondDepasseException.class)
        .hasMessageContaining("1250");

Commence par assertThat( et laisse ton IDE proposer la suite : la méthode qui correspond à ce que tu veux vérifier existe presque toujours.

Le piège des BigDecimal

assertThat(new BigDecimal("10.0")).isEqualTo(new BigDecimal("10.00")); // ❌ échoue !

Pour un BigDecimal, equals compare aussi le nombre de décimales : 10.0 et 10.00 sont différents. Pour comparer des montants, utilise :

assertThat(total).isEqualByComparingTo("10.00"); // ✅ compare la valeur

Tester un service : simuler les dépendances avec Mockito

Un service a des dépendances : un repository, un autre service, un client HTTP. Dans un test unitaire, on ne veut ni base de données, ni réseau. On les remplace par des mocks : de faux objets dont on décide le comportement.

@ExtendWith(MockitoExtension.class)
class CommandeServiceTest {

    @Mock
    ClientRepository clientRepository;

    @Mock
    CommandeRepository commandeRepository;

    CommandeService service;

    @BeforeEach
    void preparer() {
        service = new CommandeService(clientRepository, commandeRepository); // ✅ le vrai service, de faux repositories
    }

    @Test
    void refuse_une_commande_pour_un_client_inactif() {
        // Étant donné
        when(clientRepository.findById(1L)).thenReturn(Optional.of(clientInactif()));

        // Quand / Alors
        assertThatThrownBy(() -> service.commander(1L, commandeDe("100.00")))
                .isInstanceOf(ClientInactifException.class);

        verify(commandeRepository, never()).save(any()); // rien n'a été enregistré
    }
}
Instruction Signification
@Mock crée un faux objet ; par défaut, ses méthodes ne font rien et renvoient null, 0, ou une liste vide
when(...).thenReturn(...) « quand on appelle cette méthode avec ces arguments, renvoie ceci »
verify(mock).methode(...) vérifie que la méthode a été appelée
verify(mock, never()).methode(...) vérifie qu'elle n'a pas été appelée
any() n'importe quel argument

C'est l'injection par constructeur qui rend tout cela possible : on crée le service avec new, en lui passant les mocks (voir la fiche sur l'injection de dépendances).

Note : Mockito signale avec une UnnecessaryStubbingException un when(...) qui n'a servi à rien dans le test. Ce n'est pas une contrainte inutile : soit la préparation est superflue, soit le code n'a pas fait l'appel attendu. Dans les deux cas, il y a quelque chose à corriger.

Quoi simuler, quoi ne pas simuler

✅ À simuler ❌ À ne pas simuler
les repositories (la base de données) la classe testée elle-même
les clients HTTP, les API externes les DTO, les records, les objets de données : crée-les avec new
l'envoi d'e-mails, de messages les entités : utilise les vraies
l'horloge (voir plus bas) les classes de Java (List, String, LocalDate)

Si un test contient plus de when(...) que de lignes utiles, c'est souvent le signe que la classe testée a trop de dépendances.

Le piège du test qui teste le mock

@Test
void trouver_renvoie_le_client() {
    when(clientRepository.findById(1L)).thenReturn(Optional.of(alice));

    Optional<Client> resultat = clientRepository.findById(1L); // ❌ on appelle le mock, pas le service

    assertThat(resultat).contains(alice);
}

Ce test vérifie que Mockito fonctionne, pas ton code. Un test doit appeler la classe testée, et c'est elle qui appelle les mocks.

Les tests paramétrés : tester les limites

Une règle comme « pas de commande au-delà de 1 000 € » se teste en plusieurs points, surtout autour de la limite : c'est là que se cachent les erreurs (un > à la place d'un >=).

Plutôt que d'écrire cinq tests presque identiques :

@ParameterizedTest
@CsvSource({
        "999.99,  true",
        "1000.00, true",   // la limite exacte : autorisée
        "1000.01, false",  // juste au-dessus : refusée
        "5000.00, false"
})
void respecte_le_plafond(String montant, boolean accepte) {
    assertThat(plafond.autorise(new BigDecimal(montant))).isEqualTo(accepte);
}

Chaque ligne est un test à part entière : si l'une échoue, le rapport indique laquelle.

Le code qui dépend de la date ou de l'heure

public boolean estEnRetard(Facture facture) {
    return facture.getEcheance().isBefore(LocalDate.now()); // ⚠️ impossible à tester de façon fiable
}

Ce code donne un résultat différent selon le jour où le test s'exécute. Un test écrit aujourd'hui peut échouer dans un mois. La solution : injecter une horloge, qu'on peut figer dans les tests.

@Service
public class FactureService {

    private final Clock horloge;

    public FactureService(Clock horloge) {
        this.horloge = horloge;
    }

    public boolean estEnRetard(Facture facture) {
        return facture.getEcheance().isBefore(LocalDate.now(horloge)); // ✅
    }
}
@Configuration
public class HorlogeConfig {

    @Bean
    public Clock horloge() {
        return Clock.system(ZoneId.of("Europe/Paris")); // la vraie horloge, en production
    }
}
@Test
void une_facture_echue_hier_est_en_retard() {
    Clock le15Mars = Clock.fixed(Instant.parse("2026-03-15T10:00:00Z"), ZoneId.of("Europe/Paris"));
    FactureService service = new FactureService(le15Mars);

    assertThat(service.estEnRetard(factureEcheance("2026-03-14"))).isTrue();
}

Le test donne le même résultat aujourd'hui, dans un an, un 29 février ou à minuit.

Que faut-il tester ?

✅ À tester en priorité ❌ Inutile
les règles métier : plafonds, états, calculs les getters et setters
les cas d'erreur : chaque exception métier le code de Spring ou de Java lui-même
les limites : 0, la valeur exacte du seuil, une liste vide, null une méthode qui ne fait qu'appeler une autre méthode
un bug corrigé : un test qui l'aurait détecté

Le dernier point est une excellente habitude : quand tu corriges un bug, écris d'abord un test qui échoue à cause de ce bug, puis corrige-le. Le test passe au vert, et le bug ne reviendra pas sans que quelqu'un le sache.

Astuce : le taux de couverture (le pourcentage de lignes exécutées par les tests) est un indicateur, pas un objectif. Un test sans vérification exécute des lignes sans rien prouver. Mieux vaut moins de tests, mais qui vérifient vraiment les règles importantes.

Les qualités d'un bon test

Qualité Signification
rapide quelques millisecondes : on les lance des centaines de fois par jour
indépendant il ne dépend d'aucun autre test, ni de l'ordre d'exécution
reproductible même résultat sur ton poste, celui d'un collègue et le pipeline, aujourd'hui et dans un an
clair quand il échoue, son nom et son message suffisent à comprendre pourquoi
simple pas de if ni de boucle : un test qui contient de la logique peut lui-même avoir des bugs

JUnit aide pour l'indépendance : il crée une nouvelle instance de la classe de test pour chaque méthode, et @BeforeEach s'exécute avant chacune.

Lancer les tests

  • Dans l'IDE : le bouton vert à côté d'une méthode ou d'une classe de test. Le plus rapide pendant le développement.
  • Avec Maven :
mvn test     # lance tous les tests unitaires
mvn verify   # compile, lance tous les tests et les vérifications : la commande du pipeline

Maven lance par défaut les classes dont le nom se termine par Test (CommandeServiceTest). Une classe nommée CommandeServiceTests ou TestCommande peut être ignorée selon la configuration.

Lance mvn verify avant de pousser : c'est ce que fera le pipeline (voir la fiche sur les pipelines CI/CD).

Quand un test échoue

  1. Lis le message : AssertJ indique la valeur attendue et la valeur obtenue.
  2. Demande-toi qui a raison : le code ou le test ? Un test qui échoue après ta modification signale peut-être un vrai changement de comportement que tu n'avais pas prévu.
  3. Ne modifie jamais un test juste pour qu'il passe, sans comprendre pourquoi il échouait. Si la règle a vraiment changé, mets le test à jour consciemment, et le nom du test avec.

Les bonnes habitudes

  1. Un test, un comportement, structuré en Étant donné / Quand / Alors.
  2. Un nom qui se lit comme une règle.
  3. AssertJ pour les vérifications, isEqualByComparingTo pour les BigDecimal.
  4. Mocke les dépendances externes, jamais la classe testée ni les objets de données.
  5. Teste les limites avec des tests paramétrés.
  6. Injecte une Clock pour tout code qui dépend de la date ou de l'heure.
  7. Un bug corrigé = un test qui l'aurait détecté.
  8. Pas de logique, pas d'attente (Thread.sleep), pas de dépendance à l'ordre dans les tests.
  9. mvn verify avant chaque push.
  10. Ne modifie pas un test pour qu'il passe sans comprendre pourquoi il échouait.

À retenir

  • Un test unitaire vérifie une classe isolée, en quelques millisecondes, sans Spring, sans base, sans réseau.
  • Chaque test suit trois étapes : Étant donné / Quand / Alors, et vérifie un seul comportement.
  • JUnit lance les tests, AssertJ vérifie, Mockito simule les dépendances.
  • L'injection par constructeur rend un service testable : on lui passe des mocks avec new.
  • Les tests les plus utiles portent sur les règles métier, les cas d'erreur et les limites.
  • Un bon test est rapide, indépendant, reproductible et clair quand il échoue.

Avis sur l'article

Pas encore de note : soyez le premier à donner votre avis.

Les avis sont publiés après validation par le modérateur.

Votre note