Tests unitaires : JUnit, AssertJ et Mockito
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@Testimportée depuisorg.junit(sansjupiter) 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
UnnecessaryStubbingExceptionunwhen(...)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
- Lis le message : AssertJ indique la valeur attendue et la valeur obtenue.
- 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.
- 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
- Un test, un comportement, structuré en Étant donné / Quand / Alors.
- Un nom qui se lit comme une règle.
- AssertJ pour les vérifications,
isEqualByComparingTopour lesBigDecimal. - Mocke les dépendances externes, jamais la classe testée ni les objets de données.
- Teste les limites avec des tests paramétrés.
- Injecte une
Clockpour tout code qui dépend de la date ou de l'heure. - Un bug corrigé = un test qui l'aurait détecté.
- Pas de logique, pas d'attente (
Thread.sleep), pas de dépendance à l'ordre dans les tests. mvn verifyavant chaque push.- 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.