Imaginez que vous construisez une maison. Vous ne posez pas les cloisons sans avoir vérifié que les fondations sont solides. En développement logiciel, les tests jouent ce même rôle : ils vérifient que chaque brique de votre code fonctionne correctement avant d’assembler le tout.
Sans tests, voici ce qui se passe systématiquement :
Avec des tests :
Dans le secteur bancaire ou dans les applications critiques, les tests ne sont pas une option — ils sont une obligation réglementaire et professionnelle. Un virement de 50 000 € mal calculé à cause d’un bug non testé peut avoir des conséquences catastrophiques.
Pyramide des tests (de la base au sommet) ┌─────────────────┐ │ Tests E2E │ ← Peu nombreux, lents, coûteux │ (Selenium...) │ Testent l'application complète ├─────────────────┤ │ Tests d'inté- │ ← Nombre modéré │ gration │ Testent plusieurs composants ensemble ├─────────────────┤ │ Tests unitaires│ ← Très nombreux, rapides, peu coûteux │ (JUnit 5) │ Testent une seule classe/méthode └─────────────────┘
Dans ce cours, nous nous concentrons sur les tests unitaires — la fondation de tout. Vous apprendrez les tests d’intégration dans la formation Spring Boot.
Un bon test unitaire respecte le principe F.I.R.S.T. :
Un test qui se connecte à une base de données, lit un fichier ou appelle une API externe n’est pas un test unitaire. C’est un test d’intégration. Dans ce cours, nous apprendrons à simuler ces dépendances avec Mockito pour rester dans le domaine unitaire.
Le TDD (le développement piloté par les tests) est une approche qui inverse l’ordre habituel et n’est pas toujours confortable en début de pratique.
Approche classique : Code → Test → Correction Approche TDD : Test (échec) → Code → Test (succès) → Refactoring Le cycle TDD — "Red Green Refactor" ┌──────────┐ │ 🔴 RED │ Écrire un test qui ÉCHOUE └────┬─────┘ │ ┌────▼──────┐ │ 🟢 GREEN │ Écrire le MINIMUM de code pour que le test passe └────┬──────┘ │ ┌────▼──────────┐ │ 🔵 REFACTOR │ Améliorer le code SANS casser les tests └───────────────┘
Nous pratiquerons le TDD dans plusieurs exercices de ce cours.
JUnit 5 est le framework de tests unitaires de référence pour Java. Sa version 5 (sortie en 2017) est une refonte complète de JUnit 4 avec de nombreuses améliorations.
JUnit 5 est en réalité composé de trois modules :
JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage JUnit Platform → Moteur d'exécution (lance les tests) JUnit Jupiter → API de tests (les @Test, @BeforeEach, etc. que vous utilisez) JUnit Vintage → Compatibilité avec les tests JUnit 3 et 4
En pratique, vous utilisez surtout JUnit Jupiter quand vous écrivez vos tests. “JUnit 5” et “JUnit Jupiter” sont souvent utilisés comme synonymes.
Prérequis pour ce cours ├── JDK 17 (Oracle ou OpenJDK) │ └── https://adoptium.net (Temurin 17 LTS — recommandé) ├── Maven 3.8+ ou Gradle 8+ │ └── https://maven.apache.org/download.cgi ├── IntelliJ IDEA Community (gratuit) │ └── https://www.jetbrains.com/idea/download └── Git (optionnel mais recommandé) └── https://git-scm.com/download/win
# Dans un terminal PowerShell ou CMD java -version # java version "17.x.x" javac -version # javac 17.x.x mvn -version # Apache Maven 3.x.x
Si java n’est pas reconnu, ajoutez C:\Program Files\Eclipse Adoptium\jdk-17.x.x.x-hotspot\bin dans la variable d’environnement PATH. Redémarrez votre terminal après.
java
C:\Program Files\Eclipse Adoptium\jdk-17.x.x.x-hotspot\bin
PATH
Option 1 — Via IntelliJ IDEA (recommandée)
File
New Project
Maven Archetype
Archetype
maven-archetype-quickstart
GroupId
fr.formation
ArtifactId
junit5-cours
Version
1.0-SNAPSHOT
Create
Option 2 — Via la ligne de commande
mvn archetype:generate ` -DgroupId=fr.formation ` -DartifactId=junit5-cours ` -DarchetypeArtifactId=maven-archetype-quickstart ` -DarchetypeVersion=1.4 ` -DinteractiveMode=false cd junit5-cours
pom.xml
Remplacez le contenu de pom.xml par cette configuration complète :
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>fr.formation</groupId> <artifactId>junit5-cours</artifactId> <version>1.0-SNAPSHOT</version> <packaging>jar</packaging> <name>Cours JUnit 5</name> <properties> <maven.compiler.source>17</maven.compiler.source> <maven.compiler.target>17</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <!-- Versions des dépendances --> <junit.version>5.10.1</junit.version> <mockito.version>5.8.0</mockito.version> <assertj.version>3.24.2</assertj.version> <jacoco.version>0.8.11</jacoco.version> </properties> <dependencies> <!-- JUnit 5 — Framework de tests --> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>${junit.version}</version> <scope>test</scope> </dependency> <!-- Mockito — Simulation des dépendances --> <dependency> <groupId>org.mockito</groupId> <artifactId>mockito-core</artifactId> <version>${mockito.version}</version> <scope>test</scope> </dependency> <!-- Mockito extension pour JUnit 5 --> <dependency> <groupId>org.mockito</groupId> <artifactId>mockito-junit-jupiter</artifactId> <version>${mockito.version}</version> <scope>test</scope> </dependency> <!-- AssertJ — Assertions fluides (optionnel mais très pratique) --> <dependency> <groupId>org.assertj</groupId> <artifactId>assertj-core</artifactId> <version>${assertj.version}</version> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <!-- Plugin Maven Surefire — exécute les tests --> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <version>3.2.2</version> </plugin> <!-- JaCoCo — Rapport de couverture de code --> <plugin> <groupId>org.jacoco</groupId> <artifactId>jacoco-maven-plugin</artifactId> <version>${jacoco.version}</version> <executions> <execution> <goals> <goal>prepare-agent</goal> </goals> </execution> <execution> <id>report</id> <phase>test</phase> <goals> <goal>report</goal> </goals> </execution> </executions> </plugin> </plugins> </build> </project>
junit5-cours/ ├── src/ │ ├── main/ │ │ └── java/ │ │ └── fr/formation/ ← Votre code source │ │ ├── Calculatrice.java │ │ ├── service/ │ │ └── model/ │ └── test/ │ └── java/ │ └── fr/formation/ ← Vos tests (même structure de packages) │ ├── CalculatriceTest.java │ ├── service/ │ └── model/ └── pom.xml
Convention fondamentale : les classes de test se trouvent dans src/test/java et reproduisent exactement la même structure de packages que src/main/java. Si votre classe est fr.formation.service.CompteService, son test sera fr.formation.service.CompteServiceTest.
src/test/java
src/main/java
fr.formation.service.CompteService
fr.formation.service.CompteServiceTest
# Compiler le projet mvn compile # Exécuter tous les tests mvn test # Compiler + tests + package (génère le .jar) mvn package # Nettoyer les fichiers compilés mvn clean # Tout nettoyer et tout retester mvn clean test # Générer le rapport de couverture JaCoCo mvn clean test # Rapport disponible dans : target/site/jacoco/index.html
Commençons par quelque chose de simple : une calculatrice. C’est l’exemple classique, mais il permet de comprendre tous les mécanismes sans se perdre dans la complexité métier.
// src/main/java/fr/formation/Calculatrice.java package fr.formation; /** * Calculatrice simple — notre premier sujet de tests. */ public class Calculatrice { public int additionner(int a, int b) { return a + b; } public int soustraire(int a, int b) { return a - b; } public int multiplier(int a, int b) { return a * b; } public double diviser(int dividende, int diviseur) { if (diviseur == 0) { throw new ArithmeticException("Division par zéro impossible !"); } return (double) dividende / diviseur; } public boolean estPair(int nombre) { return nombre % 2 == 0; } public int maximum(int a, int b) { return a >= b ? a : b; } }
// src/test/java/fr/formation/CalculatriceTest.java package fr.formation; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class CalculatriceTest { // L'objet testé — on crée une instance pour chaque test private final Calculatrice calculatrice = new Calculatrice(); // @Test marque une méthode comme test JUnit 5 // Nom recommandé : nomMethode_contexte_resultatAttendu @Test void additionner_deuxEntiers_retourneLeurSomme() { // ── ARRANGE : préparer les données ────────────────────────── int a = 5; int b = 3; // ── ACT : appeler la méthode testée ───────────────────────── int resultat = calculatrice.additionner(a, b); // ── ASSERT : vérifier le résultat ─────────────────────────── assertEquals(8, resultat); } @Test void additionner_nombreNegatifEtPositif_retourneLeurSomme() { int resultat = calculatrice.additionner(-5, 3); assertEquals(-2, resultat); } @Test void additionner_deuxZeros_retourneZero() { assertEquals(0, calculatrice.additionner(0, 0)); } @Test void soustraire_deuxEntiers_retourneLeurDifference() { assertEquals(2, calculatrice.soustraire(5, 3)); } @Test void multiplier_deuxEntiers_retourneLeurProduit() { assertEquals(15, calculatrice.multiplier(3, 5)); } @Test void multiplier_parZero_retourneZero() { assertEquals(0, calculatrice.multiplier(42, 0)); } @Test void estPair_nombrePair_retourneTrue() { assertTrue(calculatrice.estPair(4)); } @Test void estPair_nombreImpair_retourneFalse() { assertFalse(calculatrice.estPair(7)); } @Test void estPair_zero_retourneTrue() { assertTrue(calculatrice.estPair(0)); } @Test void maximum_premierPlusGrand_retournePremier() { assertEquals(10, calculatrice.maximum(10, 3)); } @Test void maximum_deuxiemePlusGrand_retourneDeuxieme() { assertEquals(10, calculatrice.maximum(3, 10)); } @Test void maximum_deuxEgaux_retourneLunOuLautre() { assertEquals(5, calculatrice.maximum(5, 5)); } }
Dans IntelliJ IDEA :
CalculatriceTest
Run 'CalculatriceTest'
Ctrl+Shift+F10
Dans le terminal :
mvn test # Résultat attendu : # [INFO] Tests run: 12, Failures: 0, Errors: 0, Skipped: 0
Chaque test doit suivre le pattern AAA — Arrange, Act, Assert :
@Test void nomDuTest() { // ── ARRANGE (Préparer) ────────────────────────────────────── // Initialiser les objets, préparer les données d'entrée // C'est le "setup" spécifique à CE test int a = 10; int b = 4; // ── ACT (Agir) ────────────────────────────────────────────── // Appeler UNE SEULE méthode — celle que l'on teste int resultat = calculatrice.soustraire(a, b); // ── ASSERT (Vérifier) ─────────────────────────────────────── // Vérifier que le résultat correspond à ce qu'on attendait assertEquals(6, resultat, "10 - 4 devrait être égal à 6"); }
Un test doit tester une seule chose. Si vous avez besoin d’écrire “et” dans le nom de votre test (testAdditionEtSoustraction), c’est le signe que vous devriez le séparer en deux tests.
testAdditionEtSoustraction
Convertisseur
Objectif : Pratiquer la création de tests sur une classe simple.
Créez d’abord la classe à tester :
// src/main/java/fr/formation/Convertisseur.java package fr.formation; /** * Convertisseur d'unités — sujet du TP 1. */ public class Convertisseur { private static final double KM_PAR_MILE = 1.60934; private static final double KG_PAR_LIVRE = 0.453592; private static final double CELSIUS_OFFSET = 32.0; private static final double CELSIUS_RATIO = 9.0 / 5.0; /** * Convertit des miles en kilomètres. */ public double milesToKilometres(double miles) { if (miles < 0) { throw new IllegalArgumentException("La distance ne peut pas être négative."); } return miles * KM_PAR_MILE; } /** * Convertit des livres en kilogrammes. */ public double livresToKilogrammes(double livres) { if (livres < 0) { throw new IllegalArgumentException("Le poids ne peut pas être négatif."); } return livres * KG_PAR_LIVRE; } /** * Convertit des degrés Celsius en Fahrenheit. */ public double celsiusVersFahrenheit(double celsius) { return celsius * CELSIUS_RATIO + CELSIUS_OFFSET; } /** * Convertit des degrés Fahrenheit en Celsius. */ public double fahrenheitVersCelsius(double fahrenheit) { return (fahrenheit - CELSIUS_OFFSET) / CELSIUS_RATIO; } }
Votre mission : Créez ConvertisseurTest.java avec au moins 10 tests couvrant :
ConvertisseurTest.java
Correction partielle — les tests de température :
// src/test/java/fr/formation/ConvertisseurTest.java package fr.formation; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class ConvertisseurTest { private final Convertisseur convertisseur = new Convertisseur(); private static final double DELTA = 0.001; // Tolérance pour les doubles @Test void celsiusVersFahrenheit_zero_retourneTrenteDeuxDegres() { assertEquals(32.0, convertisseur.celsiusVersFahrenheit(0.0), DELTA); } @Test void celsiusVersFahrenheit_centDegres_retourneDeux centDouzeDegres() { assertEquals(212.0, convertisseur.celsiusVersFahrenheit(100.0), DELTA); } @Test void celsiusVersFahrenheit_moinsTrenteSeptVirguleHuit_retourneZeroDegre() { // -37.8°C ≈ 0°F (vérification inverse) assertEquals(0.0, convertisseur.celsiusVersFahrenheit(-17.78), 0.1); } @Test void fahrenheitVersCelsius_trenteDeux_retourneZeroDegre() { assertEquals(0.0, convertisseur.fahrenheitVersCelsius(32.0), DELTA); } @Test void milesToKilometres_valeurNegative_leveIllegalArgumentException() { assertThrows(IllegalArgumentException.class, () -> convertisseur.milesToKilometres(-1.0) ); } // À vous de compléter les autres tests ! }
JUnit 5 fournit de nombreuses méthodes d’assertion dans la classe Assertions. Voici un aperçu complet :
Assertions
// src/test/java/fr/formation/DemoAssertionsTest.java package fr.formation; import org.junit.jupiter.api.Test; import java.util.List; import java.util.Arrays; import static org.junit.jupiter.api.Assertions.*; class DemoAssertionsTest { // ── Égalité ───────────────────────────────────────────────────────────── @Test void demonstrerAssertEquals() { // assertEquals(valeurAttendue, valeurObtenue) // Toujours : attendu EN PREMIER, obtenu EN SECOND assertEquals(42, 40 + 2); assertEquals("Bonjour", "Bon" + "jour"); assertEquals(3.14159, Math.PI, 0.0001); // Avec delta pour les doubles assertEquals('A', 'A'); // Avec un message d'erreur personnalisé (3ème paramètre) assertEquals(10, 5 * 2, "5 × 2 devrait être 10"); } @Test void demonstrerAssertNotEquals() { assertNotEquals(0, 42); assertNotEquals("", "bonjour"); } // ── Booléens ───────────────────────────────────────────────────────────── @Test void demonstrerAssertTrueEtFalse() { assertTrue(5 > 3); assertTrue("hello".startsWith("hel")); assertFalse(10 == 20); assertFalse("".contains("x")); // Avec fournisseur de message (évalué SEULEMENT en cas d'échec — meilleure perf) assertTrue(42 > 0, () -> "42 devrait être positif mais vaut : " + 42); } // ── Null ───────────────────────────────────────────────────────────────── @Test void demonstrerAssertNullEtNotNull() { String valeurNulle = null; String valeurNonNulle = "texte"; assertNull(valeurNulle); assertNotNull(valeurNonNulle); assertNotNull(new Object(), "L'objet ne devrait pas être null"); } // ── Références ──────────────────────────────────────────────────────────── @Test void demonstrerAssertSameEtNotSame() { String s1 = new String("test"); String s2 = new String("test"); String s3 = s1; // Même référence assertEquals(s1, s2); // Contenu identique assertNotSame(s1, s2); // Objets différents en mémoire assertSame(s1, s3); // Même référence en mémoire } // ── Tableaux ────────────────────────────────────────────────────────────── @Test void demonstrerAssertArrayEquals() { int[] attendu = {1, 2, 3, 4, 5}; int[] obtenu = {1, 2, 3, 4, 5}; assertArrayEquals(attendu, obtenu); double[] doubles1 = {1.0, 2.0, 3.0}; double[] doubles2 = {1.001, 2.001, 3.001}; assertArrayEquals(doubles1, doubles2, 0.01); // Avec delta } // ── Itérables ──────────────────────────────────────────────────────────── @Test void demonstrerAssertIterableEquals() { List<String> liste1 = Arrays.asList("Java", "JUnit", "Maven"); List<String> liste2 = Arrays.asList("Java", "JUnit", "Maven"); assertIterableEquals(liste1, liste2); } // ── Vérifications multiples groupées ───────────────────────────────────── @Test void demonstrerAssertAll() { // assertAll : TOUS les assertions sont exécutées, même si l'une échoue // Utile pour vérifier plusieurs propriétés d'un objet d'un coup Calculatrice calc = new Calculatrice(); assertAll("vérifications calculatrice", () -> assertEquals(10, calc.additionner(5, 5)), () -> assertEquals(0, calc.soustraire(5, 5)), () -> assertEquals(25, calc.multiplier(5, 5)), () -> assertEquals(1.0, calc.diviser(5, 5)) ); // Si plusieurs assertions échouent, TOUTES les erreurs sont rapportées } // ── Timeout ────────────────────────────────────────────────────────────── @Test void demonstrerAssertTimeout() { // assertTimeout : le test doit se terminer dans le délai imparti assertTimeout( java.time.Duration.ofMillis(500), () -> { // Code qui doit s'exécuter en moins de 500ms Thread.sleep(100); } ); } }
AssertJ propose une API fluide (chaînable) qui produit des messages d’erreur beaucoup plus clairs. C’est une alternative très populaire aux assertions JUnit 5.
// src/test/java/fr/formation/DemoAssertJTest.java package fr.formation; import org.junit.jupiter.api.Test; import java.util.List; import java.util.Arrays; import static org.assertj.core.api.Assertions.*; class DemoAssertJTest { @Test void demonstrerAssertJNombres() { int resultat = 42; assertThat(resultat) .isEqualTo(42) .isGreaterThan(40) .isLessThan(50) .isPositive() .isBetween(40, 45); } @Test void demonstrerAssertJChaines() { String message = "Bonjour, le monde !"; assertThat(message) .isNotNull() .isNotEmpty() .startsWith("Bonjour") .endsWith("!") .contains("monde") .hasSize(19) .doesNotContain("cruel"); } @Test void demonstrerAssertJListes() { List<String> langages = Arrays.asList("Java", "Python", "JavaScript", "Kotlin"); assertThat(langages) .isNotEmpty() .hasSize(4) .contains("Java", "Kotlin") .doesNotContain("COBOL") .containsExactlyInAnyOrder("Python", "Java", "Kotlin", "JavaScript"); } @Test void demonstrerAssertJObjets() { // Exemple avec un objet métier Calculatrice calc = new Calculatrice(); int resultat = calc.additionner(10, 5); assertThat(resultat) .as("Le résultat de 10 + 5") // Message descriptif pour les rapports .isEqualTo(15); } @Test void demonstrerAssertJExceptions() { Calculatrice calc = new Calculatrice(); // Vérification d'exception avec AssertJ assertThatThrownBy(() -> calc.diviser(10, 0)) .isInstanceOf(ArithmeticException.class) .hasMessage("Division par zéro impossible !"); // Alternative assertThatExceptionOfType(ArithmeticException.class) .isThrownBy(() -> calc.diviser(5, 0)) .withMessage("Division par zéro impossible !"); } @Test void demonstrerAssertJSansException() { Calculatrice calc = new Calculatrice(); // Vérifier qu'aucune exception n'est levée assertThatNoException() .isThrownBy(() -> calc.diviser(10, 2)); } }
Recommandation : utilisez assertEquals pour les cas simples et AssertJ pour les vérifications complexes (listes, chaînes, objets). Dans ce cours, nous utilisons les deux pour que vous soyez à l’aise avec les deux styles.
assertEquals
Etudiant
// src/main/java/fr/formation/model/Etudiant.java package fr.formation.model; import java.util.ArrayList; import java.util.Collections; import java.util.List; /** * Représente un étudiant avec ses notes — sujet du TP 2. */ public class Etudiant { private final String nom; private final String prenom; private final List<Double> notes; public Etudiant(String nom, String prenom) { if (nom == null || nom.isBlank()) { throw new IllegalArgumentException("Le nom ne peut pas être vide."); } if (prenom == null || prenom.isBlank()) { throw new IllegalArgumentException("Le prénom ne peut pas être vide."); } this.nom = nom.trim(); this.prenom = prenom.trim(); this.notes = new ArrayList<>(); } public void ajouterNote(double note) { if (note < 0 || note > 20) { throw new IllegalArgumentException( "La note doit être comprise entre 0 et 20. Reçu : " + note ); } notes.add(note); } public double calculerMoyenne() { if (notes.isEmpty()) { throw new IllegalStateException("Impossible de calculer la moyenne : aucune note."); } return notes.stream() .mapToDouble(Double::doubleValue) .average() .orElse(0.0); } public double obtenirMeilleurNote() { if (notes.isEmpty()) { throw new IllegalStateException("Aucune note disponible."); } return Collections.max(notes); } public double obtenirPireNote() { if (notes.isEmpty()) { throw new IllegalStateException("Aucune note disponible."); } return Collections.min(notes); } public boolean estAdmis() { return calculerMoyenne() >= 10.0; } public String obtenirMention() { double moyenne = calculerMoyenne(); if (moyenne >= 16) return "Très Bien"; if (moyenne >= 14) return "Bien"; if (moyenne >= 12) return "Assez Bien"; if (moyenne >= 10) return "Passable"; return "Insuffisant"; } public String getNom() { return nom; } public String getPrenom() { return prenom; } public List<Double> getNotes() { return Collections.unmodifiableList(notes); } public int getNombreNotes() { return notes.size(); } @Override public String toString() { return prenom + " " + nom; } }
Mission : Créez EtudiantTest.java avec des tests pour TOUTES les méthodes. Voici quelques tests pour vous guider :
EtudiantTest.java
// src/test/java/fr/formation/model/EtudiantTest.java package fr.formation.model; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; import static org.junit.jupiter.api.Assertions.*; import static org.assertj.core.api.Assertions.*; class EtudiantTest { private Etudiant etudiant; @BeforeEach void setUp() { // Créer un étudiant frais avant CHAQUE test etudiant = new Etudiant("Dupont", "Alicia"); } @Test void constructeur_nomsValides_creerEtudiant() { assertThat(etudiant.getNom()).isEqualTo("Dupont"); assertThat(etudiant.getPrenom()).isEqualTo("Alicia"); assertThat(etudiant.getNotes()).isEmpty(); } @Test void constructeur_nomVide_leveIllegalArgumentException() { assertThrows(IllegalArgumentException.class, () -> new Etudiant("", "Alicia")); } @Test void constructeur_nomNull_leveIllegalArgumentException() { assertThrows(IllegalArgumentException.class, () -> new Etudiant(null, "Alicia")); } @Test void ajouterNote_noteValide_ajouteLaNote() { etudiant.ajouterNote(15.0); assertThat(etudiant.getNombreNotes()).isEqualTo(1); assertThat(etudiant.getNotes()).contains(15.0); } @Test void ajouterNote_noteNegative_leveIllegalArgumentException() { assertThatThrownBy(() -> etudiant.ajouterNote(-1.0)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("-1.0"); } @Test void ajouterNote_noteSuperieureAVingt_leveIllegalArgumentException() { assertThrows(IllegalArgumentException.class, () -> etudiant.ajouterNote(20.1)); } @Test void calculerMoyenne_troisNotes_retourneMoyenneCorrecte() { etudiant.ajouterNote(12.0); etudiant.ajouterNote(14.0); etudiant.ajouterNote(16.0); assertEquals(14.0, etudiant.calculerMoyenne(), 0.001); } @Test void calculerMoyenne_sansNote_leveIllegalStateException() { assertThrows(IllegalStateException.class, () -> etudiant.calculerMoyenne()); } @Test void estAdmis_moyenneSuperieure10_retourneTrue() { etudiant.ajouterNote(10.0); etudiant.ajouterNote(12.0); assertTrue(etudiant.estAdmis()); } @Test void estAdmis_moyenneInferieure10_retourneFalse() { etudiant.ajouterNote(8.0); etudiant.ajouterNote(9.0); assertFalse(etudiant.estAdmis()); } @Test void obtenirMention_moyenne16_retourneTresBien() { etudiant.ajouterNote(16.0); etudiant.ajouterNote(17.0); assertEquals("Très Bien", etudiant.obtenirMention()); } @Test void obtenirMention_moyenne10_retournePassable() { etudiant.ajouterNote(10.0); assertEquals("Passable", etudiant.obtenirMention()); } // À vous de compléter : tester obtenirMeilleurNote, obtenirPireNote, // les mentions "Bien", "Assez Bien", "Insuffisant", etc. }
// src/test/java/fr/formation/CycleVieTest.java package fr.formation; import org.junit.jupiter.api.*; import static org.junit.jupiter.api.Assertions.*; class CycleVieTest { // @BeforeAll : exécuté UNE FOIS avant tous les tests de la classe // Doit être static (ou nécessite @TestInstance(Lifecycle.PER_CLASS)) @BeforeAll static void initialiserUneSeuleFois() { System.out.println(" @BeforeAll — Initialisation de la classe de test"); // Idéal pour : connexion BDD coûteuse, chargement de ressources lourdes } // @AfterAll : exécuté UNE FOIS après tous les tests @AfterAll static void nettoyerApresTests() { System.out.println(" @AfterAll — Nettoyage final"); // Idéal pour : fermeture connexion, suppression fichiers temporaires } // @BeforeEach : exécuté avant CHAQUE test @BeforeEach void preparer() { System.out.println(" @BeforeEach — Avant ce test"); // Idéal pour : créer les objets testés, initialiser les mocks } // @AfterEach : exécuté après CHAQUE test @AfterEach void nettoyer() { System.out.println(" @AfterEach — Après ce test"); // Idéal pour : remettre l'état initial, vider les collections statiques } @Test void premierTest() { System.out.println(" Test 1"); assertTrue(true); } @Test void deuxiemeTest() { System.out.println(" Test 2"); assertEquals(4, 2 + 2); } @Test void troisiemeTest() { System.out.println(" Test 3"); assertNotNull("valeur"); } } /* Ordre d'exécution : ▶ @BeforeAll → @BeforeEach Test 1 ← @AfterEach → @BeforeEach Test 2 ← @AfterEach → @BeforeEach Test 3 ← @AfterEach ■ @AfterAll */
// src/test/java/fr/formation/AnnotationsAvanceesTest.java package fr.formation; import org.junit.jupiter.api.*; import org.junit.jupiter.api.condition.*; import static org.junit.jupiter.api.Assertions.*; class AnnotationsAvanceesTest { // @Disabled : désactive temporairement un test @Test @Disabled("Fonctionnalité en cours de développement — ticket #142") void fonctionnaliteEnCoursDeDeveloppement() { fail("Ce test ne devrait pas s'exécuter !"); } // @DisplayName : donne un nom lisible au test (affiché dans les rapports) @Test @DisplayName("Vérification que 1 + 1 = 2 — Test fondamental") void testAvecNomLisible() { assertEquals(2, 1 + 1); } // @EnabledOnOs : n'exécute le test que sur certains OS @Test @EnabledOnOs(OS.WINDOWS) void testSeulementSurWindows() { System.out.println("Ce test ne s'exécute que sur Windows"); assertTrue(System.getProperty("os.name").toLowerCase().contains("win")); } // @EnabledOnJre : n'exécute le test que sur certaines versions Java @Test @EnabledOnJre(JRE.JAVA_17) void testSeulementSurJava17() { System.out.println("Java 17 uniquement"); assertTrue(true); } // @Tag : étiqueter les tests pour les filtrer lors de l'exécution @Test @Tag("rapide") @Tag("calcul") void testRapide() { assertEquals(100, 10 * 10); } @Test @Tag("lent") @Tag("integration") void testLent() throws InterruptedException { Thread.sleep(100); // Simulation d'un traitement assertTrue(true); } // @RepeatedTest : répéter un test N fois @RepeatedTest(5) @DisplayName("Test répété") void testRepete(RepetitionInfo repetitionInfo) { System.out.println("Répétition " + repetitionInfo.getCurrentRepetition() + " / " + repetitionInfo.getTotalRepetitions()); assertTrue(Math.random() >= 0); // Toujours vrai — utile pour tester l'aléatoire } }
// src/test/java/fr/formation/model/EtudiantGroupesTest.java package fr.formation.model; import org.junit.jupiter.api.*; import static org.junit.jupiter.api.Assertions.*; /** * Organisation des tests en groupes logiques avec @Nested. * Très lisible dans les rapports et IDE. */ @DisplayName("Tests de la classe Etudiant") class EtudiantGroupesTest { private Etudiant etudiant; @BeforeEach void creerEtudiant() { etudiant = new Etudiant("Martin", "Pierre"); } // ── Groupe 1 : Construction ───────────────────────────────────────────── @Nested @DisplayName("Création d'un étudiant") class CreationTests { @Test @DisplayName("avec des données valides — succès") void avecDonneesValides() { assertNotNull(etudiant); assertEquals("Martin", etudiant.getNom()); } @Test @DisplayName("avec un nom null — lève IllegalArgumentException") void avecNomNull() { assertThrows(IllegalArgumentException.class, () -> new Etudiant(null, "Pierre")); } @Test @DisplayName("avec un nom avec espaces — espaces supprimés") void avecNomAvecEspaces() { Etudiant e = new Etudiant(" Durand ", "Marie"); assertEquals("Durand", e.getNom()); } } // ── Groupe 2 : Gestion des notes ───────────────────────────────────────── @Nested @DisplayName("Gestion des notes") class NotesTests { @Test @DisplayName("ajout d'une note valide — enregistrée") void ajoutNoteValide() { etudiant.ajouterNote(15.0); assertEquals(1, etudiant.getNombreNotes()); } @Test @DisplayName("ajout de plusieurs notes — toutes enregistrées") void ajoutPlusieursNotes() { etudiant.ajouterNote(10.0); etudiant.ajouterNote(14.0); etudiant.ajouterNote(18.0); assertEquals(3, etudiant.getNombreNotes()); } @Nested @DisplayName("Calcul de la moyenne") class MoyenneTests { @Test @DisplayName("avec trois notes égales — retourne cette note") void avecTroisNotesEgales() { etudiant.ajouterNote(15.0); etudiant.ajouterNote(15.0); etudiant.ajouterNote(15.0); assertEquals(15.0, etudiant.calculerMoyenne(), 0.001); } @Test @DisplayName("sans notes — lève IllegalStateException") void sansNotes() { assertThrows(IllegalStateException.class, () -> etudiant.calculerMoyenne()); } } } // ── Groupe 3 : Résultats ───────────────────────────────────────────────── @Nested @DisplayName("Résultats académiques") class ResultatsTests { @BeforeEach void ajouterNotes() { // Setup spécifique à ce groupe imbriqué etudiant.ajouterNote(12.0); etudiant.ajouterNote(14.0); } @Test @DisplayName("moyenne de 13 — étudiant admis") void etudiantAdmis() { assertTrue(etudiant.estAdmis()); } @Test @DisplayName("moyenne de 13 — mention Assez Bien") void mentionAssezBien() { assertEquals("Assez Bien", etudiant.obtenirMention()); } } }
Imaginez que vous voulez tester la méthode estPair() avec 10 valeurs différentes. Sans tests paramétrés, vous écrirez 10 méthodes presque identiques. Les tests paramétrés permettent d’exécuter le même test avec des données différentes.
estPair()
// src/test/java/fr/formation/TestsParametresTest.java package fr.formation; import org.junit.jupiter.params.*; import org.junit.jupiter.params.provider.*; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class TestsParametresTest { private final Calculatrice calc = new Calculatrice(); // ── @ValueSource : une liste de valeurs simples ────────────────────────── @ParameterizedTest @ValueSource(ints = {2, 4, 6, 8, 100, 0, -2, 1000}) @DisplayName("Nombres pairs — estPair() retourne true") void estPair_nombresPairs_retourneToujours True(int nombre) { assertTrue(calc.estPair(nombre), nombre + " devrait être pair"); } @ParameterizedTest @ValueSource(ints = {1, 3, 5, 7, 99, -1, 1001}) @DisplayName("Nombres impairs — estPair() retourne false") void estPair_nombresImpairs_retourneToujours False(int nombre) { assertFalse(calc.estPair(nombre)); } @ParameterizedTest @ValueSource(strings = {"Java", "JUnit", "Maven", "Spring"}) void toutesLesChainesCommencent AvecMajuscule(String texte) { char premierChar = texte.charAt(0); assertTrue(Character.isUpperCase(premierChar), "'" + texte + "' devrait commencer par une majuscule"); } // ── @CsvSource : plusieurs paramètres par ligne ────────────────────────── @ParameterizedTest @CsvSource({ "5, 3, 8", // 5 + 3 = 8 "0, 0, 0", // 0 + 0 = 0 "-5, 5, 0", // -5 + 5 = 0 "10, -3, 7", // 10 + (-3) = 7 "100, 200, 300" }) @DisplayName("Addition de deux entiers") void additionner_deuxEntiers_retourneSomme(int a, int b, int sommeAttendue) { assertEquals(sommeAttendue, calc.additionner(a, b), String.format("%d + %d devrait être %d", a, b, sommeAttendue)); } @ParameterizedTest @CsvSource({ "10, 2, 5.0", "9, 3, 3.0", "7, 2, 3.5", "1, 4, 0.25" }) void diviser_deuxEntiers_retourneQuotient(int dividende, int diviseur, double attendu) { assertEquals(attendu, calc.diviser(dividende, diviseur), 0.001); } // ── @CsvFileSource : données depuis un fichier CSV ──────────────────────── // Créez src/test/resources/donnees_addition.csv : // a,b,resultat // 1,2,3 // 5,5,10 // -3,7,4 /* @ParameterizedTest @CsvFileSource(resources = "/donnees_addition.csv", numLinesToSkip = 1) void additionner_depuis_fichier(int a, int b, int attendu) { assertEquals(attendu, calc.additionner(a, b)); } */ // ── @MethodSource : méthode fournissant les arguments ─────────────────── @ParameterizedTest @MethodSource("fournirDonneesMaximum") @DisplayName("Maximum de deux entiers") void maximum_deuxEntiers_retourneLeGrandDeux(int a, int b, int max) { assertEquals(max, calc.maximum(a, b)); } // La méthode source DOIT être static static java.util.stream.Stream<org.junit.jupiter.params.provider.Arguments> fournirDonneesMaximum() { return java.util.stream.Stream.of( Arguments.of(5, 3, 5), Arguments.of(3, 5, 5), Arguments.of(0, 0, 0), Arguments.of(-1, -5, -1), Arguments.of(100, 99, 100) ); } // ── @EnumSource : énumérations ─────────────────────────────────────────── @ParameterizedTest @EnumSource(JourDeLaSemaine.class) void tousLesJoursSontValides(JourDeLaSemaine jour) { assertNotNull(jour); assertNotNull(jour.name()); } @ParameterizedTest @EnumSource(value = JourDeLaSemaine.class, names = {"SAMEDI", "DIMANCHE"}) void joursWeekEnd_sontWeekEnd_retourneTrue(JourDeLaSemaine jour) { assertTrue(jour.estWeekEnd()); } } // Enum pour l'exemple @EnumSource enum JourDeLaSemaine { LUNDI, MARDI, MERCREDI, JEUDI, VENDREDI, SAMEDI, DIMANCHE; public boolean estWeekEnd() { return this == SAMEDI || this == DIMANCHE; } }
Validateur
// src/main/java/fr/formation/Validateur.java package fr.formation; /** * Validateur de données — sujet du TP 3. */ public class Validateur { /** * Valide un email selon un format basique. */ public boolean validerEmail(String email) { if (email == null || email.isBlank()) return false; // Format simplifié : contient un @, un point après le @, longueur > 5 int posArobase = email.indexOf('@'); if (posArobase <= 0) return false; String domaine = email.substring(posArobase + 1); return domaine.contains(".") && domaine.length() >= 3; } /** * Valide un mot de passe (8+ caractères, 1 majuscule, 1 chiffre). */ public boolean validerMotDePasse(String motDePasse) { if (motDePasse == null || motDePasse.length() < 8) return false; boolean aMajuscule = motDePasse.chars().anyMatch(Character::isUpperCase); boolean aChiffre = motDePasse.chars().anyMatch(Character::isDigit); return aMajuscule && aChiffre; } /** * Valide un code postal français (5 chiffres). */ public boolean validerCodePostal(String codePostal) { if (codePostal == null) return false; return codePostal.matches("\\d{5}"); } /** * Valide un numéro de téléphone français (format 0X XX XX XX XX). */ public boolean validerTelephone(String telephone) { if (telephone == null) return false; String nettoye = telephone.replaceAll("[\\s.\\-()]", ""); return nettoye.matches("0[1-9]\\d{8}"); } }
Votre mission : Créez ValidateurTest.java avec des tests paramétrés pour chaque méthode. Utilisez @ValueSource, @CsvSource et @MethodSource.
ValidateurTest.java
@ValueSource
@CsvSource
@MethodSource
Exemple de tests à écrire :
// Exemples attendus (à compléter) // Emails VALIDES : "user@exemple.fr", "prenom.nom@societe.com", "test@mail.co" // Emails INVALIDES : null, "", "sansat", "arobase@", "@domaine.fr" // Mots de passe VALIDES : "Password1", "MonMotDePasse42", "Abc12345" // Mots de passe INVALIDES : "court", "toutminuscule1", "SANSCHIFFRE" // Codes postaux VALIDES : "75001", "13000", "69001", "06000" // Codes postaux INVALIDES : "7500", "750011", "7500A", null
assertThrows
// src/test/java/fr/formation/TestsExceptionsTest.java package fr.formation; import org.junit.jupiter.api.*; import static org.junit.jupiter.api.Assertions.*; import static org.assertj.core.api.Assertions.*; class TestsExceptionsTest { private final Calculatrice calc = new Calculatrice(); // ── assertThrows : vérifie qu'une exception EST levée ──────────────────── @Test void diviser_parZero_leveArithmeticException() { // assertThrows retourne l'exception pour inspecter son message ArithmeticException exception = assertThrows( ArithmeticException.class, // Type d'exception attendu () -> calc.diviser(10, 0) // Code qui doit lever l'exception ); // Vérifier le message de l'exception assertEquals("Division par zéro impossible !", exception.getMessage()); } @Test void diviser_parZero_messageException_contientInformation() { Exception ex = assertThrows( ArithmeticException.class, () -> calc.diviser(5, 0) ); assertTrue( ex.getMessage().contains("zéro"), "Le message devrait mentionner 'zéro'" ); } // ── assertDoesNotThrow : vérifie qu'AUCUNE exception n'est levée ───────── @Test void diviser_diviseurNonZero_neLevePasException() { // Très utile pour documenter qu'un cas est valide assertDoesNotThrow( () -> calc.diviser(10, 2), "La division par 2 ne devrait pas lever d'exception" ); } // ── Exemple avec une classe métier plus réaliste ────────────────────────── @Test void creerEtudiant_nomNull_leveIllegalArgumentException() { IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> new fr.formation.model.Etudiant(null, "Pierre") ); assertAll( () -> assertNotNull(ex.getMessage()), () -> assertFalse(ex.getMessage().isBlank()) ); } @Test void ajouterNote_noteTropHaute_messageContiendValeur() { fr.formation.model.Etudiant e = new fr.formation.model.Etudiant("Test", "Test"); IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> e.ajouterNote(25.0) ); // Vérifier que le message contient la valeur problématique assertTrue(ex.getMessage().contains("25.0"), "Le message devrait mentionner la valeur invalide reçue"); } // ── Version AssertJ — plus expressif ───────────────────────────────────── @Test void diviser_parZero_avecAssertJ() { assertThatThrownBy(() -> calc.diviser(10, 0)) .isInstanceOf(ArithmeticException.class) .hasMessage("Division par zéro impossible !") .isNotNull(); } @Test void diviser_parZero_verificationComplete_avecAssertJ() { assertThatExceptionOfType(ArithmeticException.class) .isThrownBy(() -> calc.diviser(8, 0)) .withMessage("Division par zéro impossible !") .withNoCause(); // Pas d'exception encapsulée } // ── Tester une exception encapsulée (cause) ─────────────────────────────── @Test void exceptionAvecCause() { // Exemple : un service qui encapsule une exception bas niveau RuntimeException exception = assertThrows( RuntimeException.class, () -> { try { Integer.parseInt("pas_un_nombre"); } catch (NumberFormatException e) { throw new RuntimeException("Erreur de conversion", e); } } ); assertEquals("Erreur de conversion", exception.getMessage()); assertInstanceOf(NumberFormatException.class, exception.getCause()); } }
CompteBancaire
// src/main/java/fr/formation/model/CompteBancaire.java package fr.formation.model; import java.math.BigDecimal; import java.math.RoundingMode; /** * Compte bancaire simplifié — sujet du TP 4. */ public class CompteBancaire { private final String numeroCpte; private BigDecimal solde; private boolean bloque; public CompteBancaire(String numeroCpte, BigDecimal soldeInitial) { if (numeroCpte == null || numeroCpte.isBlank()) { throw new IllegalArgumentException("Le numéro de compte ne peut pas être vide."); } if (soldeInitial == null || soldeInitial.compareTo(BigDecimal.ZERO) < 0) { throw new IllegalArgumentException("Le solde initial doit être positif ou nul."); } this.numeroCpte = numeroCpte; this.solde = soldeInitial.setScale(2, RoundingMode.HALF_UP); this.bloque = false; } public void deposer(BigDecimal montant) { verifierNonBloque(); if (montant == null || montant.compareTo(BigDecimal.ZERO) <= 0) { throw new IllegalArgumentException("Le montant du dépôt doit être positif."); } solde = solde.add(montant).setScale(2, RoundingMode.HALF_UP); } public void retirer(BigDecimal montant) { verifierNonBloque(); if (montant == null || montant.compareTo(BigDecimal.ZERO) <= 0) { throw new IllegalArgumentException("Le montant du retrait doit être positif."); } if (montant.compareTo(solde) > 0) { throw new IllegalStateException( "Solde insuffisant. Solde actuel : " + solde + " €, retrait demandé : " + montant + " €" ); } solde = solde.subtract(montant).setScale(2, RoundingMode.HALF_UP); } public void virer(CompteBancaire destination, BigDecimal montant) { if (destination == null) { throw new IllegalArgumentException("Le compte destination ne peut pas être null."); } this.retirer(montant); destination.deposer(montant); } public void bloquer() { this.bloque = true; } public void debloquer() { this.bloque = false; } private void verifierNonBloque() { if (bloque) { throw new IllegalStateException("Opération impossible : le compte " + numeroCpte + " est bloqué."); } } public String getNumeroCpte() { return numeroCpte; } public BigDecimal getSolde() { return solde; } public boolean isBloque() { return bloque; } }
Votre mission : Créez CompteBancaireTest.java avec au moins 15 tests. Vous devez tester :
CompteBancaireTest.java
Dans une vraie application, vos classes dépendent d’autres classes : un service dépend d’un DAO, qui dépend d’une base de données. Pour tester le service en isolation, on remplace les dépendances réelles par des mocks — des imposteurs qui se comportent exactement comme on le souhaite.
Sans Mockito Avec Mockito ───────────────────────────── ───────────────────────────── ServiceTest ServiceTest │ │ ▼ ▼ Service ←── DAO ←── Base de Service ←── MockDAO données (simulé) │ ▼ Test LENT, FRAGILE, Test RAPIDE, STABLE, dépend de la BDD complètement isolé
// src/main/java/fr/formation/repository/EtudiantRepository.java package fr.formation.repository; import fr.formation.model.Etudiant; import java.util.List; import java.util.Optional; /** * Interface de persistance des étudiants. * En production : implémentée par une classe qui accède à la BDD. * En test : simulée par Mockito. */ public interface EtudiantRepository { Etudiant sauvegarder(Etudiant etudiant); Optional<Etudiant> trouverParNom(String nom); List<Etudiant> trouverTous(); boolean supprimer(String nom); long compter(); }
// src/main/java/fr/formation/service/EtudiantService.java package fr.formation.service; import fr.formation.model.Etudiant; import fr.formation.repository.EtudiantRepository; import java.util.List; import java.util.Optional; /** * Service métier pour les étudiants. * Contient la logique métier — SANS accès direct à la BDD. */ public class EtudiantService { private final EtudiantRepository repository; // Injection par constructeur — facilite les tests public EtudiantService(EtudiantRepository repository) { if (repository == null) { throw new IllegalArgumentException("Le repository ne peut pas être null."); } this.repository = repository; } public Etudiant inscrire(String nom, String prenom) { // Vérifier que l'étudiant n'existe pas déjà Optional<Etudiant> existant = repository.trouverParNom(nom); if (existant.isPresent()) { throw new IllegalStateException("Un étudiant avec le nom '" + nom + "' existe déjà."); } Etudiant nouvel = new Etudiant(nom, prenom); return repository.sauvegarder(nouvel); } public Etudiant trouverOuEchouer(String nom) { return repository.trouverParNom(nom) .orElseThrow(() -> new IllegalArgumentException("Étudiant introuvable : " + nom)); } public List<Etudiant> listerTous() { return repository.trouverTous(); } public int compterEtudiants() { return (int) repository.compter(); } public boolean supprimer(String nom) { trouverOuEchouer(nom); // Vérifie qu'il existe avant de supprimer return repository.supprimer(nom); } public String calculerBilanPromotion() { List<Etudiant> tous = repository.trouverTous(); if (tous.isEmpty()) return "Promotion vide."; long admis = tous.stream() .filter(e -> !e.getNotes().isEmpty() && e.estAdmis()) .count(); return String.format("Promotion : %d étudiants, %d admis, %d en échec.", tous.size(), admis, tous.size() - admis); } }
// src/test/java/fr/formation/service/EtudiantServiceTest.java package fr.formation.service; import fr.formation.model.Etudiant; import fr.formation.repository.EtudiantRepository; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.mockito.junit.jupiter.MockitoExtension; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; // @ExtendWith active l'intégration Mockito + JUnit 5 @ExtendWith(MockitoExtension.class) class EtudiantServiceTest { // @Mock crée automatiquement un mock de EtudiantRepository @Mock private EtudiantRepository repository; // @InjectMocks crée EtudiantService ET injecte les mocks automatiquement @InjectMocks private EtudiantService service; // ── Tests : inscrire() ─────────────────────────────────────────────────── @Test @DisplayName("inscrire — étudiant nouveau — sauvegarde et retourne l'étudiant") void inscrire_etudiantNouveau_sauvegarde() { // ARRANGE String nom = "Dupont"; String prenom = "Alice"; Etudiant attendu = new Etudiant(nom, prenom); // when().thenReturn() — définir le comportement du mock when(repository.trouverParNom(nom)) .thenReturn(Optional.empty()); // Pas encore en base when(repository.sauvegarder(any(Etudiant.class))) .thenReturn(attendu); // ACT Etudiant resultat = service.inscrire(nom, prenom); // ASSERT assertNotNull(resultat); assertEquals(nom, resultat.getNom()); assertEquals(prenom, resultat.getPrenom()); // verify() — vérifier que les méthodes ont été appelées verify(repository).trouverParNom(nom); // Appelé exactement 1 fois verify(repository).sauvegarder(any(Etudiant.class)); // Appelé 1 fois avec n'importe quel Etudiant } @Test @DisplayName("inscrire — étudiant déjà existant — lève IllegalStateException") void inscrire_etudiantExistant_leveException() { // ARRANGE Etudiant existant = new Etudiant("Martin", "Bob"); when(repository.trouverParNom("Martin")) .thenReturn(Optional.of(existant)); // ACT & ASSERT IllegalStateException ex = assertThrows( IllegalStateException.class, () -> service.inscrire("Martin", "Charlie") ); assertThat(ex.getMessage()).contains("Martin"); // Vérifier que sauvegarder() N'a PAS été appelé verify(repository, never()).sauvegarder(any()); } // ── Tests : trouverOuEchouer() ─────────────────────────────────────────── @Test @DisplayName("trouverOuEchouer — étudiant existant — retourne l'étudiant") void trouverOuEchouer_etudiantExiste_retourneEtudiant() { Etudiant alice = new Etudiant("Dupont", "Alice"); when(repository.trouverParNom("Dupont")) .thenReturn(Optional.of(alice)); Etudiant trouve = service.trouverOuEchouer("Dupont"); assertEquals(alice, trouve); verify(repository, times(1)).trouverParNom("Dupont"); } @Test @DisplayName("trouverOuEchouer — étudiant inexistant — lève IllegalArgumentException") void trouverOuEchouer_etudiantInexistant_leveException() { when(repository.trouverParNom("Inconnu")) .thenReturn(Optional.empty()); assertThatThrownBy(() -> service.trouverOuEchouer("Inconnu")) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("Inconnu"); } // ── Tests : listerTous() ───────────────────────────────────────────────── @Test @DisplayName("listerTous — plusieurs étudiants — retourne la liste complète") void listerTous_plusieursEtudiants_retourneListe() { List<Etudiant> listeAttendue = Arrays.asList( new Etudiant("Martin", "Alice"), new Etudiant("Dupont", "Bob"), new Etudiant("Bernard", "Charlie") ); when(repository.trouverTous()).thenReturn(listeAttendue); List<Etudiant> resultat = service.listerTous(); assertThat(resultat).hasSize(3); assertThat(resultat).isEqualTo(listeAttendue); } @Test @DisplayName("listerTous — aucun étudiant — retourne liste vide") void listerTous_aucunEtudiant_retourneListeVide() { when(repository.trouverTous()).thenReturn(Collections.emptyList()); assertThat(service.listerTous()).isEmpty(); } // ── Tests : supprimer() ────────────────────────────────────────────────── @Test @DisplayName("supprimer — étudiant existant — retourne true") void supprimer_etudiantExistant_retourneTrue() { Etudiant alice = new Etudiant("Dupont", "Alice"); when(repository.trouverParNom("Dupont")).thenReturn(Optional.of(alice)); when(repository.supprimer("Dupont")).thenReturn(true); boolean resultat = service.supprimer("Dupont"); assertTrue(resultat); // Vérifier l'ORDRE des appels InOrder ordre = inOrder(repository); ordre.verify(repository).trouverParNom("Dupont"); ordre.verify(repository).supprimer("Dupont"); } @Test @DisplayName("supprimer — étudiant inexistant — lève exception sans supprimer") void supprimer_etudiantInexistant_leveException() { when(repository.trouverParNom("Fantome")).thenReturn(Optional.empty()); assertThrows(IllegalArgumentException.class, () -> service.supprimer("Fantome")); verify(repository, never()).supprimer(anyString()); } // ── Tests : calculerBilanPromotion() ───────────────────────────────────── @Test @DisplayName("calculerBilanPromotion — promotion vide — message spécifique") void calculerBilanPromotion_promotionVide_retourneMessageVide() { when(repository.trouverTous()).thenReturn(Collections.emptyList()); String bilan = service.calculerBilanPromotion(); assertEquals("Promotion vide.", bilan); } @Test @DisplayName("calculerBilanPromotion — 3 étudiants dont 2 admis") void calculerBilanPromotion_troisEtudiants_retourneStatistiques() { Etudiant alice = new Etudiant("Martin", "Alice"); alice.ajouterNote(14.0); // Admis Etudiant bob = new Etudiant("Dupont", "Bob"); bob.ajouterNote(8.0); // Non admis Etudiant charlie = new Etudiant("Bernard", "Charlie"); charlie.ajouterNote(12.0); // Admis when(repository.trouverTous()) .thenReturn(Arrays.asList(alice, bob, charlie)); String bilan = service.calculerBilanPromotion(); assertThat(bilan).contains("3 étudiants", "2 admis", "1 en échec"); } }
// src/test/java/fr/formation/service/MockitoAvanceTest.java package fr.formation.service; import fr.formation.model.Etudiant; import fr.formation.repository.EtudiantRepository; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.mockito.junit.jupiter.MockitoExtension; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; import static org.mockito.ArgumentMatchers.*; @ExtendWith(MockitoExtension.class) class MockitoAvanceTest { @Mock private EtudiantRepository repository; @InjectMocks private EtudiantService service; // ── ArgumentCaptor : capturer les arguments passés au mock ────────────── @Test @DisplayName("inscrire — capturer l'étudiant sauvegardé") void inscrire_capturerEtudiantSauvegarde() { // ArgumentCaptor : capturer l'objet passé à sauvegarder() ArgumentCaptor<Etudiant> captor = ArgumentCaptor.forClass(Etudiant.class); when(repository.trouverParNom(anyString())).thenReturn(Optional.empty()); when(repository.sauvegarder(captor.capture())) .thenAnswer(inv -> inv.getArgument(0)); // Retourner l'argument reçu service.inscrire("Durand", "Emma"); // Inspecter l'objet qui a été passé à sauvegarder() Etudiant etudiantSauvegarde = captor.getValue(); assertEquals("Durand", etudiantSauvegarde.getNom()); assertEquals("Emma", etudiantSauvegarde.getPrenom()); assertTrue(etudiantSauvegarde.getNotes().isEmpty()); } // ── thenThrow : simuler une exception dans le mock ─────────────────────── @Test @DisplayName("listerTous — exception BDD — se propage") void listerTous_exceptiionBDD_sePropageAuService() { // Simuler une erreur de base de données when(repository.trouverTous()) .thenThrow(new RuntimeException("Connexion BDD perdue")); assertThrows(RuntimeException.class, () -> service.listerTous()); } // ── thenAnswer : réponse dynamique ─────────────────────────────────────── @Test @DisplayName("sauvegarder avec réponse dynamique") void sauvegarder_avecReponseDynamique() { // thenAnswer : calculer la réponse en fonction de l'argument reçu when(repository.sauvegarder(any(Etudiant.class))) .thenAnswer(invocation -> { Etudiant recu = invocation.getArgument(0); // Simuler l'ajout d'un ID par la BDD System.out.println("Mock BDD : sauvegarde de " + recu.getNom()); return recu; }); when(repository.trouverParNom(anyString())).thenReturn(Optional.empty()); Etudiant resultat = service.inscrire("Test", "Test"); assertNotNull(resultat); } // ── Appels multiples : comportement différent à chaque appel ───────────── @Test @DisplayName("comportement différent selon l'appel") void comportementDifferentSelonAppel() { // Retourner des valeurs différentes à chaque appel when(repository.compter()) .thenReturn(0L) // 1er appel .thenReturn(1L) // 2ème appel .thenReturn(2L); // 3ème appel et suivants assertEquals(0, service.compterEtudiants()); assertEquals(1, service.compterEtudiants()); assertEquals(2, service.compterEtudiants()); } // ── verify avec ArgumentMatchers ───────────────────────────────────────── @Test @DisplayName("vérifications avec matchers") void verificationsAvecMatchers() { Etudiant existant = new Etudiant("Martin", "Alice"); when(repository.trouverParNom(eq("Martin"))).thenReturn(Optional.of(existant)); when(repository.supprimer(anyString())).thenReturn(true); service.supprimer("Martin"); // Différents matchers de vérification verify(repository, times(1)).trouverParNom(eq("Martin")); verify(repository, atLeastOnce()).supprimer(anyString()); verify(repository, atMost(1)).supprimer(anyString()); // Vérifier qu'il n'y a plus d'interactions non vérifiées verifyNoMoreInteractions(repository); } }
// src/main/java/fr/formation/model/Livre.java package fr.formation.model; public class Livre { private final String isbn; private final String titre; private final String auteur; private int quantiteStock; public Livre(String isbn, String titre, String auteur, int quantiteStock) { this.isbn = isbn; this.titre = titre; this.auteur = auteur; this.quantiteStock = quantiteStock; } public boolean estDisponible() { return quantiteStock > 0; } public void diminuerStock() { if (quantiteStock > 0) quantiteStock--; } public void augmenterStock() { quantiteStock++; } public String getIsbn() { return isbn; } public String getTitre() { return titre; } public String getAuteur() { return auteur; } public int getQuantiteStock() { return quantiteStock; } }
// src/main/java/fr/formation/repository/LivreRepository.java package fr.formation.repository; import fr.formation.model.Livre; import java.util.List; import java.util.Optional; public interface LivreRepository { Optional<Livre> trouverParIsbn(String isbn); Livre sauvegarder(Livre livre); List<Livre> trouverDisponibles(); }
// src/main/java/fr/formation/service/LibrairieService.java package fr.formation.service; import fr.formation.model.Livre; import fr.formation.repository.LivreRepository; import java.util.List; public class LibrairieService { private final LivreRepository livreRepository; public LibrairieService(LivreRepository livreRepository) { this.livreRepository = livreRepository; } public Livre emprunterLivre(String isbn) { Livre livre = livreRepository.trouverParIsbn(isbn) .orElseThrow(() -> new IllegalArgumentException("Livre introuvable : " + isbn)); if (!livre.estDisponible()) { throw new IllegalStateException("Livre non disponible : " + livre.getTitre()); } livre.diminuerStock(); return livreRepository.sauvegarder(livre); } public Livre retournerLivre(String isbn) { Livre livre = livreRepository.trouverParIsbn(isbn) .orElseThrow(() -> new IllegalArgumentException("Livre inconnu : " + isbn)); livre.augmenterStock(); return livreRepository.sauvegarder(livre); } public List<Livre> obtenirCatalogue() { return livreRepository.trouverDisponibles(); } }
Votre mission : Créez LibrairieServiceTest.java en mockant LivreRepository et testez toutes les méthodes de LibrairieService (emprunter un livre disponible, emprunter un livre indisponible, retourner un livre, obtenir le catalogue vide…).
LibrairieServiceTest.java
LivreRepository
LibrairieService
// Convention recommandée : nomMethode_contexte_resultatAttendu // Lisible même sans ouvrir le code // Bon @Test void calculerMoyenne_troisNotes_retourneMoyenneArithmetique() @Test void deposer_montantNegatif_leveIllegalArgumentException() @Test void inscrire_etudiantDejaExistant_leveIllegalStateException() @Test void listerTous_aucunEtudiant_retourneListeVide() // Moins bon (trop vague) @Test void testMoyenne() @Test void testDepotErreur() @Test void test1()
// Mauvais — teste trop de choses à la fois @Test void testCompteComplet() { CompteBancaire compte = new CompteBancaire("001", new BigDecimal("100")); compte.deposer(new BigDecimal("50")); assertEquals(new BigDecimal("150.00"), compte.getSolde()); compte.retirer(new BigDecimal("30")); assertEquals(new BigDecimal("120.00"), compte.getSolde()); compte.bloquer(); assertThrows(IllegalStateException.class, () -> compte.deposer(BigDecimal.ONE)); assertTrue(compte.isBloque()); // Si un assert échoue, les suivants ne s'exécutent pas } // Bon — chaque test est focalisé @Test void deposer_soldeCentEuros_soldePasse150() { ... } @Test void retirer_soldeSuffisant_soldeDiminue() { ... } @Test void deposer_compteBloque_leveException() { ... }
// Mauvais — les tests dépendent de l'ordre d'exécution class ProblemeOrdreTest { private static CompteBancaire compte; // ← Partagé et modifié @Test void premierTest() { compte = new CompteBancaire("001", BigDecimal.TEN); compte.deposer(BigDecimal.TEN); // compte.solde = 20 maintenant } @Test void deuxiemeTest() { // Suppose que premierTest() s'est exécuté avant ! assertEquals(new BigDecimal("20.00"), compte.getSolde()); } } // Bon — chaque test crée son propre contexte class BonOrdreTest { private CompteBancaire compte; @BeforeEach void setUp() { // Remis à zéro avant CHAQUE test — état initial garanti compte = new CompteBancaire("001", new BigDecimal("100")); } @Test void test1() { compte.deposer(BigDecimal.TEN); assertEquals(...); } @Test void test2() { compte.retirer(BigDecimal.TEN); assertEquals(...); } }
// Tester les frontières — les bugs se cachent souvent là ! class TestsFrontieres { private final Etudiant e = creerEtudiant(); // Valeur juste en dessous de la limite @Test void ajouterNote_zero_estValide() { assertDoesNotThrow(() -> e.ajouterNote(0.0)); } @Test void ajouterNote_vingt_estValide() { assertDoesNotThrow(() -> e.ajouterNote(20.0)); } // Valeur à la limite exacte @Test void ajouterNote_dixVirgulePrecis_admis() { e.ajouterNote(10.0); assertTrue(e.estAdmis()); } // Valeur juste au-dessus de la limite @Test void ajouterNote_neufVirguleDix_nonAdmis() { e.ajouterNote(9.9); assertFalse(e.estAdmis()); } // Valeur invalide juste en dehors @Test void ajouterNote_moinsUn_leveException() { assertThrows(IllegalArgumentException.class, () -> e.ajouterNote(-0.1)); } @Test void ajouterNote_vingtVirguleUn_leveException() { assertThrows(IllegalArgumentException.class, () -> e.ajouterNote(20.1)); } private static Etudiant creerEtudiant() { return new Etudiant("Test", "Test"); } }
// src/test/java/fr/formation/util/EtudiantMere.java package fr.formation.util; import fr.formation.model.Etudiant; /** * Fabrique d'objets Etudiant pour les tests. * Pattern "Object Mother" — centralise la création des données de test. */ public class EtudiantMere { public static Etudiant etudiantStandard() { Etudiant e = new Etudiant("Dupont", "Alice"); e.ajouterNote(12.0); e.ajouterNote(14.0); return e; // Moyenne 13 — admis — mention Assez Bien } public static Etudiant etudiantTresBien() { Etudiant e = new Etudiant("Martin", "Bob"); e.ajouterNote(17.0); e.ajouterNote(18.0); e.ajouterNote(16.0); return e; // Moyenne 17 — admis — mention Très Bien } public static Etudiant etudiantEnEchec() { Etudiant e = new Etudiant("Bernard", "Charlie"); e.ajouterNote(5.0); e.ajouterNote(7.0); return e; // Moyenne 6 — non admis } public static Etudiant etudiantSansNote() { return new Etudiant("Renard", "Diana"); } public static Etudiant etudiantAvecNom(String nom, String prenom) { return new Etudiant(nom, prenom); } }
// Utilisation dans un test @Test void calculerBilanPromotion_deuxAdmisSurTrois() { when(repository.trouverTous()) .thenReturn(Arrays.asList( EtudiantMere.etudiantTresBien(), EtudiantMere.etudiantStandard(), EtudiantMere.etudiantEnEchec() )); String bilan = service.calculerBilanPromotion(); assertThat(bilan).contains("2 admis", "1 en échec"); }
Un test d’intégration vérifie que plusieurs classes collaborent correctement ensemble — sans pour autant démarrer un serveur Spring Boot. Il teste les interactions réelles entre les couches, sans base de données externe.
Test unitaire : Service testé avec un Mock du repository Test d'intégration : Service testé avec une VRAIE implémentation en mémoire Test Spring Boot : Serveur complet démarré avec BDD H2 (vu dans la prochaine formation)
// src/main/java/fr/formation/repository/impl/EtudiantRepositoryMemoire.java package fr.formation.repository.impl; import fr.formation.model.Etudiant; import fr.formation.repository.EtudiantRepository; import java.util.*; /** * Implémentation en mémoire du repository. * Utilisée pour les tests d'intégration — pas besoin de BDD. */ public class EtudiantRepositoryMemoire implements EtudiantRepository { private final Map<String, Etudiant> stockage = new HashMap<>(); @Override public Etudiant sauvegarder(Etudiant etudiant) { stockage.put(etudiant.getNom(), etudiant); return etudiant; } @Override public Optional<Etudiant> trouverParNom(String nom) { return Optional.ofNullable(stockage.get(nom)); } @Override public List<Etudiant> trouverTous() { return new ArrayList<>(stockage.values()); } @Override public boolean supprimer(String nom) { return stockage.remove(nom) != null; } @Override public long compter() { return stockage.size(); } // Méthode utilitaire pour les tests public void vider() { stockage.clear(); } }
// src/test/java/fr/formation/integration/EtudiantServiceIntegrationTest.java package fr.formation.integration; import fr.formation.model.Etudiant; import fr.formation.repository.impl.EtudiantRepositoryMemoire; import fr.formation.service.EtudiantService; import org.junit.jupiter.api.*; import static org.junit.jupiter.api.Assertions.*; import static org.assertj.core.api.Assertions.*; /** * Tests d'intégration — Service + Repository en mémoire. * Pas de mocks : on utilise de vraies implémentations. */ @DisplayName("Tests d'intégration — EtudiantService") class EtudiantServiceIntegrationTest { // Vraie implémentation — pas de mock ici private EtudiantRepositoryMemoire repository; private EtudiantService service; @BeforeEach void setUp() { repository = new EtudiantRepositoryMemoire(); service = new EtudiantService(repository); } @Test @DisplayName("scénario complet : inscrire, trouver, supprimer") void scenarioComplet_inscrireTrouverSupprimer() { // Inscrire Etudiant alice = service.inscrire("Dupont", "Alice"); assertNotNull(alice); assertEquals(1, service.compterEtudiants()); // Trouver Etudiant trouve = service.trouverOuEchouer("Dupont"); assertEquals("Alice", trouve.getPrenom()); // Supprimer boolean supprime = service.supprimer("Dupont"); assertTrue(supprime); assertEquals(0, service.compterEtudiants()); } @Test @DisplayName("inscription de deux étudiants différents — tous deux présents") void inscrireDeux_etudiantsDifferents_deuxPresents() { service.inscrire("Martin", "Alice"); service.inscrire("Dupont", "Bob"); assertThat(service.listerTous()).hasSize(2); } @Test @DisplayName("inscrire le même nom deux fois — lève exception") void inscrireMemeNom_deuxFois_exception() { service.inscrire("Martin", "Alice"); assertThrows(IllegalStateException.class, () -> service.inscrire("Martin", "Alice Bis")); // La deuxième tentative n'a pas modifié le stockage assertEquals(1, service.compterEtudiants()); } @Test @DisplayName("bilan promotion avec notes réelles") void bilanPromotion_avecNotesReelles() { Etudiant alice = service.inscrire("Martin", "Alice"); Etudiant bob = service.inscrire("Dupont", "Bob"); Etudiant eve = service.inscrire("Bernard", "Eve"); // Ajouter des notes directement aux objets alice.ajouterNote(15.0); // admis bob.ajouterNote(8.0); // non admis eve.ajouterNote(12.0); // admis // Sauvegarder l'état mis à jour repository.sauvegarder(alice); repository.sauvegarder(bob); repository.sauvegarder(eve); String bilan = service.calculerBilanPromotion(); assertThat(bilan) .contains("3 étudiants") .contains("2 admis") .contains("1 en échec"); } }
La couverture de code mesure quelle proportion de votre code source est exécutée par vos tests. JaCoCo est l’outil standard pour Java.
Types de couverture mesurés par JaCoCo : Couverture de lignes : % de lignes de code exécutées Couverture de branches : % de branches (if/else, switch) testées Couverture d'instructions: % d'instructions bytecode exécutées Couverture de méthodes : % de méthodes appelées Couverture de classes : % de classes instanciées/utilisées
# Exécuter les tests ET générer le rapport mvn clean test # Ouvrir le rapport dans votre navigateur # Windows : ouvrir target/site/jacoco/index.html start target\site\jacoco\index.html
Le rapport affiche un code couleur :
<!-- Dans pom.xml — ajouter dans la configuration du plugin JaCoCo --> <execution> <id>jacoco-check</id> <goals> <goal>check</goal> </goals> <configuration> <rules> <rule> <element>BUNDLE</element> <limits> <!-- Le build ÉCHOUE si la couverture globale < 70% --> <limit> <counter>LINE</counter> <value>COVEREDRATIO</value> <minimum>0.70</minimum> </limit> <limit> <counter>BRANCH</counter> <value>COVEREDRATIO</value> <minimum>0.60</minimum> </limit> </limits> </rule> </rules> </configuration> </execution>
Objectif réaliste : viser 80% de couverture de lignes pour le code métier. Évitez l’obsession du 100% — certains codes (constructeurs, getters/setters simples) n’ont pas besoin d’être testés explicitement.
Ce TP final met en pratique tout ce que vous avez appris dans ce cours. Vous allez développer un mini-système de gestion bancaire complet, en écrivant les tests en même temps que le code (approche TDD recommandée).
Système BanqueSimple ├── model/ │ ├── Client.java ← Un client de la banque │ ├── CompteEpargne.java ← Compte avec taux d'intérêts │ └── CompteCourant.java ← Compte avec découvert autorisé ├── repository/ │ └── ClientRepository.java ← Interface de persistance │ └── impl/ │ └── ClientRepositoryMemoire.java ├── service/ │ ├── CompteService.java ← Logique des opérations sur comptes │ └── ClientService.java ← Gestion des clients └── exception/ ├── ClientInexistantException.java └── SoldeInsuffisantException.java
Étape 1 — Exceptions personnalisées
// src/main/java/fr/formation/banque/exception/ClientInexistantException.java package fr.formation.banque.exception; public class ClientInexistantException extends RuntimeException { public ClientInexistantException(String identifiant) { super("Aucun client trouvé avec l'identifiant : " + identifiant); } }
// src/main/java/fr/formation/banque/exception/SoldeInsuffisantException.java package fr.formation.banque.exception; import java.math.BigDecimal; public class SoldeInsuffisantException extends RuntimeException { public SoldeInsuffisantException(BigDecimal solde, BigDecimal montant) { super(String.format( "Solde insuffisant. Disponible : %.2f €, Demandé : %.2f €", solde, montant )); } }
Étape 2 — La classe Client
Client
// src/main/java/fr/formation/banque/model/Client.java package fr.formation.banque.model; import java.util.ArrayList; import java.util.Collections; import java.util.List; /** * Client de la banque. * Un client peut posséder plusieurs comptes. */ public class Client { private final String identifiant; // Numéro client unique private String nom; private String prenom; private String email; private final List<CompteBancaireBase> comptes; public Client(String identifiant, String nom, String prenom, String email) { if (identifiant == null || identifiant.isBlank()) throw new IllegalArgumentException("L'identifiant ne peut pas être vide."); if (nom == null || nom.isBlank()) throw new IllegalArgumentException("Le nom ne peut pas être vide."); if (prenom == null || prenom.isBlank()) throw new IllegalArgumentException("Le prénom ne peut pas être vide."); if (email == null || !email.contains("@")) throw new IllegalArgumentException("L'email est invalide."); this.identifiant = identifiant.trim(); this.nom = nom.trim(); this.prenom = prenom.trim(); this.email = email.trim(); this.comptes = new ArrayList<>(); } public void ajouterCompte(CompteBancaireBase compte) { if (compte == null) throw new IllegalArgumentException("Le compte ne peut pas être null."); if (comptes.stream().anyMatch(c -> c.getNumero().equals(compte.getNumero()))) { throw new IllegalStateException("Ce compte est déjà associé au client."); } comptes.add(compte); } public BigDecimalTotal calculerPatrimoine() { return comptes.stream() .map(CompteBancaireBase::getSolde) .reduce(java.math.BigDecimal.ZERO, java.math.BigDecimal::add); } public String getIdentifiant() { return identifiant; } public String getNom() { return nom; } public String getPrenom() { return prenom; } public String getEmail() { return email; } public void setEmail(String email) { if (email == null || !email.contains("@")) throw new IllegalArgumentException("L'email est invalide."); this.email = email; } public List<CompteBancaireBase> getComptes() { return Collections.unmodifiableList(comptes); } public int getNombreComptes() { return comptes.size(); } @Override public String toString() { return prenom + " " + nom + " (" + identifiant + ")"; } }
La méthode calculerPatrimoine() utilise BigDecimalTotal qui n’existe pas — c’est voulu. Remplacez-la par java.math.BigDecimal. C’est une erreur intentionnelle pour que vous pratiquiez la lecture et la compréhension du code.
calculerPatrimoine()
BigDecimalTotal
java.math.BigDecimal
Étape 3 — La classe abstraite CompteBancaireBase
CompteBancaireBase
// src/main/java/fr/formation/banque/model/CompteBancaireBase.java package fr.formation.banque.model; import fr.formation.banque.exception.SoldeInsuffisantException; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Collections; import java.util.List; /** * Classe abstraite de base pour tous les types de comptes. */ public abstract class CompteBancaireBase { protected final String numero; protected BigDecimal solde; protected boolean bloque; protected final List<String> historiqueOperations; protected CompteBancaireBase(String numero, BigDecimal soldeInitial) { if (numero == null || numero.isBlank()) throw new IllegalArgumentException("Le numéro de compte est obligatoire."); if (soldeInitial == null || soldeInitial.compareTo(BigDecimal.ZERO) < 0) throw new IllegalArgumentException("Le solde initial doit être positif ou nul."); this.numero = numero; this.solde = soldeInitial.setScale(2, RoundingMode.HALF_UP); this.bloque = false; this.historiqueOperations = new ArrayList<>(); enregistrerOperation("Ouverture du compte avec solde initial : " + solde + " €"); } public void deposer(BigDecimal montant) { verifierNonBloque(); if (montant == null || montant.compareTo(BigDecimal.ZERO) <= 0) throw new IllegalArgumentException("Le montant du dépôt doit être strictement positif."); solde = solde.add(montant).setScale(2, RoundingMode.HALF_UP); enregistrerOperation("Dépôt : +" + montant + " € → Solde : " + solde + " €"); } public abstract void retirer(BigDecimal montant); protected void retirerInterne(BigDecimal montant, BigDecimal soldeMinimumAutorise) { verifierNonBloque(); if (montant == null || montant.compareTo(BigDecimal.ZERO) <= 0) throw new IllegalArgumentException("Le montant du retrait doit être strictement positif."); BigDecimal soldeApres = solde.subtract(montant); if (soldeApres.compareTo(soldeMinimumAutorise) < 0) { throw new SoldeInsuffisantException(solde, montant); } solde = soldeApres.setScale(2, RoundingMode.HALF_UP); enregistrerOperation("Retrait : -" + montant + " € → Solde : " + solde + " €"); } public void bloquer() { this.bloque = true; enregistrerOperation("Compte bloqué."); } public void debloquer() { this.bloque = false; enregistrerOperation("Compte débloqué."); } protected void verifierNonBloque() { if (bloque) throw new IllegalStateException("Opération refusée : le compte " + numero + " est bloqué."); } protected void enregistrerOperation(String description) { historiqueOperations.add("[" + LocalDateTime.now() + "] " + description); } public String getNumero() { return numero; } public BigDecimal getSolde() { return solde; } public boolean isBloque() { return bloque; } public List<String> getHistorique() { return Collections.unmodifiableList(historiqueOperations); } }
Étape 4 — CompteCourant et CompteEpargne
CompteCourant
CompteEpargne
// src/main/java/fr/formation/banque/model/CompteCourant.java package fr.formation.banque.model; import java.math.BigDecimal; /** * Compte courant avec découvert autorisé configurable. */ public class CompteCourant extends CompteBancaireBase { private final BigDecimal decouvertAutorise; public CompteCourant(String numero, BigDecimal soldeInitial, BigDecimal decouvertAutorise) { super(numero, soldeInitial); if (decouvertAutorise == null || decouvertAutorise.compareTo(BigDecimal.ZERO) < 0) throw new IllegalArgumentException("Le découvert autorisé doit être positif ou nul."); this.decouvertAutorise = decouvertAutorise; } public CompteCourant(String numero, BigDecimal soldeInitial) { this(numero, soldeInitial, BigDecimal.ZERO); // Pas de découvert par défaut } @Override public void retirer(BigDecimal montant) { // Le minimum autorisé est l'opposé du découvert (ex: -500 si découvert 500€) retirerInterne(montant, decouvertAutorise.negate()); } public BigDecimal getDecouvertAutorise() { return decouvertAutorise; } public BigDecimal getMontantDisponible() { return solde.add(decouvertAutorise); } }
// src/main/java/fr/formation/banque/model/CompteEpargne.java package fr.formation.banque.model; import java.math.BigDecimal; import java.math.RoundingMode; /** * Compte épargne avec taux d'intérêts annuel. * Pas de solde négatif autorisé. */ public class CompteEpargne extends CompteBancaireBase { private final BigDecimal tauxAnnuel; public CompteEpargne(String numero, BigDecimal soldeInitial, BigDecimal tauxAnnuel) { super(numero, soldeInitial); if (tauxAnnuel == null || tauxAnnuel.compareTo(BigDecimal.ZERO) < 0) throw new IllegalArgumentException("Le taux annuel doit être positif ou nul."); if (tauxAnnuel.compareTo(new BigDecimal("1.0")) > 0) throw new IllegalArgumentException("Le taux annuel ne peut pas dépasser 100%."); this.tauxAnnuel = tauxAnnuel; } @Override public void retirer(BigDecimal montant) { retirerInterne(montant, BigDecimal.ZERO); // Pas de découvert } /** * Calcule et applique les intérêts mensuels. * @return Le montant des intérêts crédités. */ public BigDecimal appliquerInteretsMensuels() { verifierNonBloque(); BigDecimal interets = solde .multiply(tauxAnnuel) .divide(new BigDecimal("12"), 2, RoundingMode.HALF_UP); if (interets.compareTo(BigDecimal.ZERO) > 0) { solde = solde.add(interets).setScale(2, RoundingMode.HALF_UP); enregistrerOperation("Intérêts mensuels : +" + interets + " € → Solde : " + solde + " €"); } return interets; } public BigDecimal getTauxAnnuel() { return tauxAnnuel; } }
Étape 5 — Interface et implémentation du repository
// src/main/java/fr/formation/banque/repository/ClientRepository.java package fr.formation.banque.repository; import fr.formation.banque.model.Client; import java.util.List; import java.util.Optional; public interface ClientRepository { Client sauvegarder(Client client); Optional<Client> trouverParIdentifiant(String identifiant); Optional<Client> trouverParEmail(String email); List<Client> trouverTous(); boolean supprimer(String identifiant); boolean existeParIdentifiant(String identifiant); long compter(); }
// src/main/java/fr/formation/banque/repository/impl/ClientRepositoryMemoire.java package fr.formation.banque.repository.impl; import fr.formation.banque.model.Client; import fr.formation.banque.repository.ClientRepository; import java.util.*; public class ClientRepositoryMemoire implements ClientRepository { private final Map<String, Client> stockage = new LinkedHashMap<>(); @Override public Client sauvegarder(Client client) { stockage.put(client.getIdentifiant(), client); return client; } @Override public Optional<Client> trouverParIdentifiant(String identifiant) { return Optional.ofNullable(stockage.get(identifiant)); } @Override public Optional<Client> trouverParEmail(String email) { return stockage.values().stream() .filter(c -> c.getEmail().equalsIgnoreCase(email)) .findFirst(); } @Override public List<Client> trouverTous() { return new ArrayList<>(stockage.values()); } @Override public boolean supprimer(String identifiant) { return stockage.remove(identifiant) != null; } @Override public boolean existeParIdentifiant(String identifiant) { return stockage.containsKey(identifiant); } @Override public long compter() { return stockage.size(); } public void vider() { stockage.clear(); } }
Étape 6 — Le service ClientService
ClientService
// src/main/java/fr/formation/banque/service/ClientService.java package fr.formation.banque.service; import fr.formation.banque.exception.ClientInexistantException; import fr.formation.banque.model.Client; import fr.formation.banque.model.CompteBancaireBase; import fr.formation.banque.repository.ClientRepository; import java.util.List; public class ClientService { private final ClientRepository clientRepository; public ClientService(ClientRepository clientRepository) { if (clientRepository == null) throw new IllegalArgumentException("Le repository ne peut pas être null."); this.clientRepository = clientRepository; } public Client creerClient(String identifiant, String nom, String prenom, String email) { if (clientRepository.existeParIdentifiant(identifiant)) { throw new IllegalStateException("Un client avec l'identifiant '" + identifiant + "' existe déjà."); } if (clientRepository.trouverParEmail(email).isPresent()) { throw new IllegalStateException("Un client avec l'email '" + email + "' existe déjà."); } Client nouveau = new Client(identifiant, nom, prenom, email); return clientRepository.sauvegarder(nouveau); } public Client trouverClient(String identifiant) { return clientRepository.trouverParIdentifiant(identifiant) .orElseThrow(() -> new ClientInexistantException(identifiant)); } public void ajouterCompte(String identifiantClient, CompteBancaireBase compte) { Client client = trouverClient(identifiantClient); client.ajouterCompte(compte); clientRepository.sauvegarder(client); } public List<Client> listerClients() { return clientRepository.trouverTous(); } public boolean supprimerClient(String identifiant) { trouverClient(identifiant); // Lève l'exception si inexistant return clientRepository.supprimer(identifiant); } public long compterClients() { return clientRepository.compter(); } }
Mission A — Tests unitaires de CompteCourant
Créez CompteCourantTest.java avec les tests suivants :
CompteCourantTest.java
Cas à tester : ✅ Construction valide (numéro, solde positif, découvert positif) ✅ Construction avec découvert nul (comportement par défaut) ❌ Construction avec numéro vide → IllegalArgumentException ❌ Construction avec solde négatif → IllegalArgumentException ❌ Construction avec découvert négatif → IllegalArgumentException ✅ Dépôt augmente le solde ✅ Retrait dans les limites du solde → solde diminue ✅ Retrait dans les limites du découvert → solde négatif ❌ Retrait au-delà du découvert → SoldeInsuffisantException ✅ getMontantDisponible() = solde + découvert ✅ Opérations sur compte bloqué → IllegalStateException ✅ Blocage puis déblocage → opérations de nouveau possibles
Mission B — Tests unitaires de CompteEpargne
Créez CompteEpargneTest.java :
CompteEpargneTest.java
Cas à tester : ✅ Construction valide ❌ Taux supérieur à 100% → IllegalArgumentException ❌ Taux négatif → IllegalArgumentException ✅ Retrait jusqu'au solde zéro ❌ Retrait au-delà du solde (pas de découvert) → SoldeInsuffisantException ✅ appliquerInteretsMensuels() avec solde 1200€ et taux 3% → 3€ ✅ appliquerInteretsMensuels() avec solde 0€ → 0€ ✅ Intérêts augmentent bien le solde ❌ Intérêts sur compte bloqué → IllegalStateException
Mission C — Tests unitaires de Client
Créez ClientTest.java :
ClientTest.java
Cas à tester : ✅ Construction valide ❌ Construction avec email sans @ → IllegalArgumentException ✅ ajouterCompte() avec un compte valide ❌ ajouterCompte() avec le même compte deux fois → IllegalStateException ✅ calculerPatrimoine() avec deux comptes (somme des soldes) ✅ calculerPatrimoine() sans compte → zéro ✅ setEmail() avec email valide ❌ setEmail() avec email invalide → IllegalArgumentException
Mission D — Tests avec Mockito de ClientService
Créez ClientServiceTest.java avec @ExtendWith(MockitoExtension.class) :
ClientServiceTest.java
@ExtendWith(MockitoExtension.class)
Méthodes à tester : creerClient() : ✅ Données valides — client créé et sauvegardé ❌ Identifiant déjà existant → IllegalStateException ❌ Email déjà utilisé → IllegalStateException Vérification : sauvegarder() appelé exactement 1 fois Vérification : sauvegarder() jamais appelé si doublon trouverClient() : ✅ Client existant → retourné ❌ Client inexistant → ClientInexistantException supprimerClient() : ✅ Client existant → true Vérification : trouverParIdentifiant() avant supprimer() ❌ Client inexistant → exception sans appel à supprimer() listerClients() / compterClients() : ✅ Délèguent correctement au repository
Mission E — Tests d’intégration
Créez ClientServiceIntegrationTest.java en utilisant ClientRepositoryMemoire :
ClientServiceIntegrationTest.java
ClientRepositoryMemoire
Scénarios : ✅ Créer un client + ajouter deux comptes + vérifier patrimoine ✅ Créer deux clients + lister → deux clients présents ✅ Créer + supprimer → liste vide ✅ Créer avec même email deux fois → exception, un seul client ✅ Scénario virement entre deux comptes de clients différents
Mission F (Bonus) — Tests paramétrés
Créez ValidationsTest.java avec des tests paramétrés pour valider :
ValidationsTest.java
@ParameterizedTest avec les identifiants : "C001", "C002", "CLIENT-123" @ParameterizedTest avec les emails invalides : null, "", "sansarobase", "@domaine" @ParameterizedTest avec les montants de dépôt invalides : 0, -1, -100 @ParameterizedTest avec les taux valides : 0.0, 0.03, 0.10, 1.0 @ParameterizedTest avec les taux invalides : -0.01, 1.01, 2.0
// src/test/java/fr/formation/banque/model/CompteCourantTest.java package fr.formation.banque.model; import fr.formation.banque.exception.SoldeInsuffisantException; import org.junit.jupiter.api.*; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import java.math.BigDecimal; import static org.junit.jupiter.api.Assertions.*; import static org.assertj.core.api.Assertions.*; @DisplayName("Tests de CompteCourant") class CompteCourantTest { private CompteCourant compte; @BeforeEach void setUp() { // Compte avec 500€ et découvert autorisé de 200€ compte = new CompteCourant("CC001", new BigDecimal("500.00"), new BigDecimal("200.00")); } // ── Construction ───────────────────────────────────────────────────────── @Test @DisplayName("Construction valide — compte créé avec les bons attributs") void construction_parametresValides_compteCreeSoldeCorrect() { assertAll( () -> assertEquals("CC001", compte.getNumero()), () -> assertEquals(new BigDecimal("500.00"), compte.getSolde()), () -> assertEquals(new BigDecimal("200.00"), compte.getDecouvertAutorise()), () -> assertFalse(compte.isBloque()), () -> assertEquals(new BigDecimal("700.00"), compte.getMontantDisponible()) ); } @Test @DisplayName("Construction sans découvert — découvert à zéro") void construction_sansDecouvert_decouvertNul() { CompteCourant c = new CompteCourant("CC002", new BigDecimal("100.00")); assertEquals(BigDecimal.ZERO, c.getDecouvertAutorise()); } @Test @DisplayName("Construction avec numéro vide — lève IllegalArgumentException") void construction_numeroVide_leveIllegalArgumentException() { assertThrows(IllegalArgumentException.class, () -> new CompteCourant("", new BigDecimal("100"))); } @Test @DisplayName("Construction avec solde négatif — lève IllegalArgumentException") void construction_soldeNegatif_leveIllegalArgumentException() { assertThrows(IllegalArgumentException.class, () -> new CompteCourant("CC003", new BigDecimal("-1"))); } @Test @DisplayName("Construction avec découvert négatif — lève IllegalArgumentException") void construction_decouvertNegatif_leveIllegalArgumentException() { assertThrows(IllegalArgumentException.class, () -> new CompteCourant("CC004", BigDecimal.ZERO, new BigDecimal("-100"))); } // ── Dépôts ─────────────────────────────────────────────────────────────── @Test @DisplayName("Dépôt valide — solde augmente") void deposer_montantValide_soldeAugmente() { compte.deposer(new BigDecimal("100.00")); assertEquals(new BigDecimal("600.00"), compte.getSolde()); } @ParameterizedTest @ValueSource(doubles = {0.0, -1.0, -100.0}) @DisplayName("Dépôt montant invalide — lève IllegalArgumentException") void deposer_montantInvalide_leveIllegalArgumentException(double montant) { assertThrows(IllegalArgumentException.class, () -> compte.deposer(BigDecimal.valueOf(montant))); } // ── Retraits ───────────────────────────────────────────────────────────── @Test @DisplayName("Retrait dans le solde — solde diminue") void retirer_dansLeSolde_soldeDiminue() { compte.retirer(new BigDecimal("200.00")); assertEquals(new BigDecimal("300.00"), compte.getSolde()); } @Test @DisplayName("Retrait jusqu'au bout du découvert — solde à -200") void retirer_jusquAuDecouvert_soldeNegatifAutorise() { // 500 + 200 (découvert) = 700 disponibles compte.retirer(new BigDecimal("700.00")); assertEquals(new BigDecimal("-200.00"), compte.getSolde()); } @Test @DisplayName("Retrait au-delà du découvert — lève SoldeInsuffisantException") void retirer_auDelaDecouvert_leveSoldeInsuffisantException() { assertThatThrownBy(() -> compte.retirer(new BigDecimal("700.01"))) .isInstanceOf(SoldeInsuffisantException.class) .hasMessageContaining("500.00") // Le solde actuel .hasMessageContaining("700.01"); // Le montant demandé } // ── Compte bloqué ──────────────────────────────────────────────────────── @Test @DisplayName("Dépôt sur compte bloqué — lève IllegalStateException") void deposer_compteBloque_leveIllegalStateException() { compte.bloquer(); assertThrows(IllegalStateException.class, () -> compte.deposer(BigDecimal.TEN)); } @Test @DisplayName("Retrait sur compte bloqué — lève IllegalStateException") void retirer_compteBloque_leveIllegalStateException() { compte.bloquer(); assertThrows(IllegalStateException.class, () -> compte.retirer(BigDecimal.TEN)); } @Test @DisplayName("Déblocage — opérations de nouveau possibles") void debloquer_apresBlockage_operationsPossibles() { compte.bloquer(); compte.debloquer(); assertDoesNotThrow(() -> compte.deposer(BigDecimal.TEN)); } // ── Historique ─────────────────────────────────────────────────────────── @Test @DisplayName("Historique — contient l'ouverture et les opérations") void historique_apresOperations_contientTraces() { compte.deposer(new BigDecimal("100")); compte.retirer(new BigDecimal("50")); assertThat(compte.getHistorique()) .hasSizeGreaterThanOrEqualTo(3) // Ouverture + dépôt + retrait .anyMatch(op -> op.contains("Ouverture")) .anyMatch(op -> op.contains("Dépôt")) .anyMatch(op -> op.contains("Retrait")); } }
@Test
@BeforeEach
@AfterEach
@BeforeAll
@AfterAll
@DisplayName
@Nested
@ParameterizedTest
@Disabled
@RepeatedTest
@Tag
@Timeout
// JUnit 5 Assertions assertEquals(attendu, obtenu) // Égalité assertNotEquals(a, b) // Inégalité assertTrue(condition) // Vrai assertFalse(condition) // Faux assertNull(valeur) // Null assertNotNull(valeur) // Non null assertSame(ref1, ref2) // Même référence assertArrayEquals(tab1, tab2) // Tableaux égaux assertThrows(ExceptionClass, () -> {}) // Lève une exception assertDoesNotThrow(() -> {}) // Ne lève pas d'exception assertAll("groupe", () -> {...}, ...) // Toutes les assertions // AssertJ (plus expressif) assertThat(valeur).isEqualTo(attendu) assertThat(texte).contains("mot").startsWith("début") assertThat(liste).hasSize(3).contains("Java") assertThatThrownBy(() -> {}).isInstanceOf(Ex.class)
// Créer un mock @Mock MonInterface mock; MonInterface mock = Mockito.mock(MonInterface.class); // Définir le comportement when(mock.methode(arg)).thenReturn(valeur); when(mock.methode(any())).thenThrow(new RuntimeException()); when(mock.methode(any())).thenAnswer(inv -> inv.getArgument(0)); // Vérifier les appels verify(mock).methode(arg); // Appelé 1 fois verify(mock, times(2)).methode(arg); // Appelé 2 fois exactement verify(mock, never()).methode(arg); // Jamais appelé verify(mock, atLeast(1)).methode(arg); // Au moins 1 fois // Capturer les arguments ArgumentCaptor<MonType> cap = ArgumentCaptor.forClass(MonType.class); verify(mock).methode(cap.capture()); MonType valeurCapturee = cap.getValue(); // Matchers any() // N'importe quoi (non null) anyString() // N'importe quelle String eq("exact") // Valeur exacte contains("sous") // Contient une sous-chaîne
Organisation ☐ Classes de test dans src/test/java (même package que le code source) ☐ Nommage : NomClasseTest.java ☐ Méthodes nommées : methode_contexte_resultatAttendu Structure des tests ☐ Chaque test suit le pattern AAA (Arrange / Act / Assert) ☐ Un test = une seule vérification principale ☐ @BeforeEach utilisé pour la création des objets testés ☐ Pas de dépendance entre les tests (ordre indépendant) Couverture ☐ Cas nominaux (chemin heureux) testés ☐ Cas limites (valeurs frontières) testés ☐ Cas d'erreur (exceptions) testés ☐ Couverture JaCoCo > 70% (objectif 80%) Mockito ☐ @ExtendWith(MockitoExtension.class) sur la classe ☐ @Mock pour les dépendances ☐ @InjectMocks pour la classe testée ☐ verify() utilisé pour contrôler les interactions ☐ Pas de mocks pour les objets simples sans dépendances Qualité ☐ mvn clean test passe sans erreur ☐ Pas de @Disabled oublié ☐ Messages d'erreur informatifs dans les assertions ☐ Pas de System.out.println() dans les tests de production