POO en Java · L2 · Section 5/7
Gestion des erreurs et tests
Progression
#Gestion des erreurs, tests et observabilité
La robustesse d'une application Java ne naît pas d'outils isolés, mais d'un triptyque cohérent : une stratégie d'exception lisible, un socle de tests crédible, une observabilité qui raconte ce qui se produit en production. Ce chapitre fiabilise couche par couche la petite banque construite depuis le début du module.
Prérequis : chapitre Modélisation objet (invariants de
CompteBancaire,Virement) ; notions de test unitaire.Objectifs : distinguer exceptions vérifiées et non vérifiées et bâtir une hiérarchie utile ; écrire des tests lisibles avec JUnit 5 et AssertJ ; savoir quand utiliser Mockito, Testcontainers et Pitest ; instrumenter logs et métriques.
#1. Concevoir une hiérarchie d'exceptions utile
Le langage distingue les exceptions vérifiées (checked), que le compilateur oblige à traiter, et les non vérifiées (RuntimeException), réservées aux erreurs de programmation. Plutôt que d'enchaîner les throw new RuntimeException("..."), définissez une hiérarchie qui reflète le domaine :
1public sealed class BanqueException extends Exception permits2 CompteIntrouvableException,3 DecouvertInterditException,4 InfrastructureIndisponibleException {5 protected BanqueException(String message) {6 super(message);7 }8}9 10public final class DecouvertInterditException extends BanqueException {11 public DecouvertInterditException(BigDecimal solde, BigDecimal montant) {12 super("Découvert refusé: solde=" + solde + ", tentative=" + montant);13 }14}La grille de décision :
- Cas métier que l'appelant doit gérer (compte introuvable, plafond dépassé) : exception vérifiée du domaine, donc visible dans la signature.
- Bug du programmeur (argument nul, invariant cassé) :
IllegalArgumentException,IllegalStateException, jamais attrapés à la frontière. - Panne d'infrastructure (base injoignable) : classe à part, convertie en réponse ou en tentative de reprise par la couche adaptatrice.
À chaque frontière (REST, CLI, batch), un gestionnaire transforme l'exception en réponse HTTP structurée, en message utilisateur ou en code de sortie ; aucune exception du domaine ne doit fuir vers l'utilisateur brut.
#2. Construire une pyramide de tests durable
Une fois les invariants posés, les tests deviennent la première ligne de défense. JUnit 5 structure les scénarios, AssertJ clarifie les intentions :
1import org.junit.jupiter.api.Test;2import java.math.BigDecimal;3import static org.assertj.core.api.Assertions.*;4 5class CompteBancaireTest {6 7 @Test8 void debiter_refuse_le_decouvert() {9 var compte = new CompteBancaire("FR76...", new BigDecimal("100"));10 11 assertThatThrownBy(() -> compte.debiter(new BigDecimal("200")))12 .isInstanceOf(DecouvertInterditException.class)13 .hasMessageContaining("Découvert refusé");14 }Le premier test nomme le comportement et non la méthode : il documente le contrat. La pyramide guide la répartition :
- Base large, unitaire : logique métier pure, mémoire seulement, millisecondes.
- Étage, intégration : quelques composants réels assemblés ; Testcontainers démarre un PostgreSQL éphémère pour valider le mapping sur de vraies requêtes.
- Sommet, bout en bout : un seul parcours critique, lent, exécuté rarement.
Mockito reste pertinent pour isoler les interactions (le service a bien appelé la notation deux fois), à condition de ne pas moker ce que l'on ne maîtrise pas. Dernier étage de qualité : Pitest (mutation testing) modifie le bytecode (inverse une comparaison, retire un appel) et vérifie que vos tests détectent la mutation ; un mutant survivant signale une assertion trop faible.
#3. Observer ce qui se passe en production
Exceptions propres et tests solides ne suffisent pas : il faut voir l'application en marche. SLF4J + Logback fournissent une API uniforme et une configuration souple. Trois règles suffisent à éviter les logs inutiles : journaliser les décisions et les anomalies, pas le détail ; toujours passer l'exception en dernier argument (la pile est alors complète) ; ne jamais concaténer dans le message, les accolades s'en chargent.
1import org.slf4j.Logger;2import org.slf4j.LoggerFactory;3 4private static final Logger LOGGER = LoggerFactory.getLogger(ServiceVirement.class);5 6public void effectuerVirement(Virement virement) throws BanqueException {7 try {8 compteService.debiter(virement.source(), virement.montant());9 compteService.crediter(virement.cible(), virement.montant());10 LOGGER.info("Virement {} -> {} reussi", virement.source(), virement.cible());11 } catch (BanqueException e) {12 LOGGER.warn("Virement refuse", e);13 throw e;14 }Ajoutez des métriques temps réel avec Micrometer :
1import io.micrometer.core.instrument.Metrics;2import io.micrometer.core.instrument.Timer;3 4private final Timer virementTimer = Metrics.timer("banque.virement.latence");5 6public void traiter(Virement virement) throws BanqueException {7 virementTimer.record(() -> effectuerVirement(virement));8}Les métriques s'exportent vers Prometheus ; OpenTelemetry collecte les traces distribuées pour suivre une requête de bout en bout (REST -> file de messages -> traitement). Ces éléments deviennent indispensables dès qu'il faut diagnostiquer un ralentissement ou prouver une conformité réglementaire.
#4. Atelier guidé
- Enrichissez la hiérarchie d'exceptions pour distinguer validation (IBAN invalide) et indisponibilité d'infrastructure ; configurez un gestionnaire de frontière (
@ControllerAdvicedans Spring) qui convertit chaque type en code HTTP pertinent : 422 pour la validation, 503 pour l'infrastructure. - Écrivez un test d'intégration complet : PostgreSQL démarré par Testcontainers, insertion d'un client, déclenchement d'un virement, vérification des écritures en base par requête SQL.
- Ajoutez un
Timeret unCounterautour du pipeline de virement, exposez-les sur/actuator/prometheus, puis simulez une saturation de la base et observez la latence monter dans la métrique pendant que les traces OpenTelemetry montrent l'attente sur la connexion.
Vérification observable pour l'atelier 1 : une requête avec IBAN invalide renvoie 422 et un corps JSON contenant le champ erreur, une base coupée renvoie 503 ; aucun code HTTP 500 ne doit apparaître dans les deux cas.