Aller au contenu

Tests Unitaires avec JUnit 5 — Cours complet pour développeur.euse.s Java

Java 17 - JUnit 5 - Mockito - Maven - Windows


Sommaire


1. Introduction aux tests unitaires

1.1. Pourquoi tester son code ?

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.

1.2. Les différents types de tests

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
        └─────────────────┘
Type Ce qu’il teste Vitesse Quantité recommandée
Unitaire Une classe ou méthode isolée Très rapide (ms) 70% des tests
Intégration Plusieurs couches ensemble Moyenne (s) 20% des tests
E2E (bout en bout) L’application entière Lent (min) 10% des tests

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.

1.3. Qu’est-ce qu’un bon test unitaire ?

Un bon test unitaire respecte le principe F.I.R.S.T. :

Lettre Anglais Signification
F Fast Rapide — se termine en millisecondes
I Independent Indépendant — ne dépend pas d’autres tests
R Repeatable Reproductible — même résultat à chaque exécution
S Self-validating Auto-validant — succès ou échec sans intervention humaine
T Timely À temps — écrit en même temps que le code

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.

1.4. TDD — Test-Driven Development

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.

1.5. JUnit 5 — Présentation

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.


2. Installation et environnement Windows

2.1. Prérequis

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

2.2. Vérification de l’environnement

# 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.

2.3. Créer un projet Maven pour ce cours

Option 1 — Via IntelliJ IDEA (recommandée)

  1. FileNew Project
  2. Choisir Maven Archetype
  3. Archetype : maven-archetype-quickstart
  4. GroupId : fr.formation
  5. ArtifactId : junit5-cours
  6. Version : 1.0-SNAPSHOT
  7. Cliquer 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

2.4. Configuration du 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>

2.5. Structure du projet

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.

2.6. Commandes Maven essentielles

# 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

3. Premiers tests avec JUnit 5

3.1. Votre toute première classe à tester

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;
    }
}

3.2. Votre premier test JUnit 5

// 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));
    }
}

3.3. Exécuter les tests

Dans IntelliJ IDEA :

Dans le terminal :

mvn test

# Résultat attendu :
# [INFO] Tests run: 12, Failures: 0, Errors: 0, Skipped: 0

3.4. Comprendre la structure AAA

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.

3.5. TP 1 — Classe Convertisseur à tester

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 :

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 !
}

4. Les assertions JUnit 5

4.1. Vue d’ensemble des assertions

JUnit 5 fournit de nombreuses méthodes d’assertion dans la classe Assertions. Voici un aperçu complet :

// 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);
            }
        );
    }
}

4.2. Les assertions AssertJ — Plus lisibles

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.

4.3. TP 2 — Classe Etudiant : tester un objet métier

// 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 :

// 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.
}

5. Organisation et cycle de vie des tests

5.1. Les annotations de cycle de vie

// 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
*/

5.2. Désactiver, ignorer et conditionner des tests

// 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
    }
}

5.3. Classes de tests imbriquées

// 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());
        }
    }
}

6. Tests paramétrés

6.1. Pourquoi les tests paramétrés ?

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.

// 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; }
}

6.2. TP 3 — Tests paramétrés sur un 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.

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

7. Tester les exceptions

7.1. assertThrows — La méthode de référence

// 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());
    }
}

7.2. TP 4 — Tester une classe 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 :


8. Mockito — Simuler les dépendances

8.1. Pourquoi simuler des dépendances ?

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é

8.2. Premier exemple — Service avec dépendance

// 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);
    }
}

8.3. Tests avec Mockito

// 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");
    }
}

8.4. Fonctionnalités avancées de Mockito

// 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);
    }
}

8.5. TP 5 — Service de Librairie avec Mockito

// 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…).


9. Bonnes pratiques et patterns de tests

9.1. Nommage des tests — Convention claire

//  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()

9.2. Un test = une seule vérification principale

//  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()   { ... }

9.3. Éviter les dépendances entre tests

//  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(...); }
}

9.4. Tester les valeurs limites — Technique des frontières

//  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"); }
}

9.5. Pattern Object Mother — Fabriquer des données de 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");
}

10. Tests d’intégration sans Spring

10.1. Qu’est-ce qu’un test d’intégration sans Spring ?

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)

10.2. Implémentation en mémoire du repository

// 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();
    }
}

10.3. Tests d’intégration

// 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");
    }
}

11. Couverture de code avec JaCoCo

11.1. Comprendre la couverture

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

11.2. Générer et lire le rapport JaCoCo

# 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 :

11.3. Ajouter une contrainte de couverture minimale

<!-- 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.


12. TP Final — Système de gestion bancaire

12.1. Présentation du TP final

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

12.2. Les classes à implémenter

É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

// 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.

Étape 3 — La classe abstraite 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

// 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

// 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();
    }
}

12.3. Vos missions de tests

Mission A — Tests unitaires de CompteCourant

Créez CompteCourantTest.java avec les tests suivants :

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 :

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 :

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) :

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 :

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 :

@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

12.4. Critères de réussite du TP Final

Critère Points
Mission A — Tests CompteCourant (12+ tests) 15 pts
Mission B — Tests CompteEpargne (10+ tests) 15 pts
Mission C — Tests Client (8+ tests) 10 pts
Mission D — Tests Mockito ClientService (12+ tests) 25 pts
Mission E — Tests d’intégration (5+ scénarios) 20 pts
Mission F — Tests paramétrés (bonus) 15 pts
Respect des conventions de nommage 5 pts
Couverture JaCoCo > 80% 10 pts
Total 115 pts

12.5. Correction partielle — Tests CompteCourant

// 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"));
    }
}

Annexe — Aide-mémoire et ressources

Récapitulatif des annotations JUnit 5

Annotation Description Fréquence d’usage
@Test Marque une méthode comme test ⭐⭐⭐⭐⭐
@BeforeEach Avant chaque test ⭐⭐⭐⭐⭐
@AfterEach Après chaque test ⭐⭐⭐
@BeforeAll Avant tous les tests (static) ⭐⭐
@AfterAll Après tous les tests (static) ⭐⭐
@DisplayName Nom lisible dans les rapports ⭐⭐⭐⭐
@Nested Grouper des tests en sous-classes ⭐⭐⭐⭐
@ParameterizedTest Test avec données multiples ⭐⭐⭐⭐⭐
@ValueSource Source de valeurs simples ⭐⭐⭐⭐⭐
@CsvSource Source CSV inline ⭐⭐⭐⭐
@MethodSource Source depuis une méthode ⭐⭐⭐⭐
@Disabled Désactiver temporairement ⭐⭐⭐
@RepeatedTest Répéter N fois ⭐⭐
@Tag Étiqueter pour filtrer ⭐⭐
@Timeout Limiter le temps d’exécution ⭐⭐

Récapitulatif des assertions essentielles

// 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)

Récapitulatif Mockito

// 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

Checklist avant de rendre votre code

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

Ressources pour aller plus loin

Ressource URL
Documentation JUnit 5 https://junit.org/junit5/docs/current/user-guide
Documentation Mockito https://javadoc.io/doc/org.mockito/mockito-core/latest
Documentation AssertJ https://assertj.github.io/doc
JaCoCo https://www.jacoco.org/jacoco/trunk/doc
Baeldung — JUnit 5 https://www.baeldung.com/junit-5
Effective Unit Testing (livre) Lasse Koskela — Manning Publications
Test-Driven Development (livre) Kent Beck — Addison-Wesley