CodeGym /Cours /Hibernate deep-dive /Suite de régression ORM

Suite de régression ORM

Hibernate deep-dive
Niveau 28, Leçon 4
Disponible

1. Introduction

Si vous avez déjà vu un projet où les tests sont éparpillés « comme des chaussettes après le lavage » (en apparence propres, mais l’un dans le dossier service, l’autre dans util, et un troisième carrément dans OldTests2), alors vous savez déjà à quoi sert une structure. Une suite de régression ORM n’est pas simplement une multitude de tests. C’est un ensemble organisé de conventions qui vous protège contre le retour d’anciens râteaux ORM.

Les problèmes Hibernate sont perfides car ils reviennent souvent « en catimini ». Aujourd’hui, vous corrigez soigneusement le N+1 avec JOIN FETCH, et demain quelqu’un fait un refactoring « anodin », change une méthode de repository – et tout recommence à faire des dégâts, mais en production. Une suite de régression est nécessaire pour que ces incidents soient détectés non par les utilisateurs, mais par le voyant vert/rouge des tests.

Un modèle mental utile ici est le suivant : chaque test de régression ORM est un petit contrat sur le comportement de la couche de persistance, où le contrat inclut non seulement le résultat métier (« la commande s’est chargée »), mais aussi une attente technique (« chargée sans N+1 », « une opération bulk nécessite un clear() avant la vérification » ou « merge() retourne une copie gérée, pas l’objet original ressuscité »). Si les conventions ne sont pas couchées par écrit dans les tests, elles deviennent « une connaissance dans la tête d’une seule personne », ce qui est le stockage le moins fiable de la planète.

2. Découpage par fonctionnalités et risques

L’erreur la plus fréquente lors de l’organisation des tests ORM est : « créons un dossier repository et mettons tout dedans ». Cela semble logique les deux premiers jours, mais ensuite on obtient une seule classe de test géante de 800 lignes, où cohabitent soft delete, merge, N+1 et « vérification d’unicité du SKU ». Une telle classe est impossible à lire, donc impossible à maintenir.

Dans Commerce Persistence Lab, nous avons déjà un indice architectural fort : le projet suit le principe package-by-feature. Cela signifie qu’il est raisonnable de répartir les tests selon les mêmes « features », pour que le cerveau n’ait pas à basculer entre différentes sous-tâches du domaine. Ensuite, au sein d’une feature, nous classons les tests par type de risque ORM : fetching, sémantique d’écriture, effets de bord bulk, visibilité soft delete, verrouillage.

Un petit arbre indicatif (ce n’est pas un dogme, mais un « minimum lisible ») :


src/test/java/com/example/commerce
├─ catalog
│  ├─ fetching
│  │  └─ ProductFetchingRegressionTest.java
│  ├─ projection
│  │  └─ ProductProjectionRegressionTest.java
│  ├─ bulk
│  │  └─ ProductBulkUpdateRegressionTest.java
│  └─ softdelete
│     └─ ProductSoftDeleteVisibilityTest.java
├─ orders
│  ├─ merge
│  │  └─ PurchaseOrderMergeRegressionTest.java
│  ├─ flush
│  │  └─ PurchaseOrderFlushBehaviorTest.java
│  └─ fetching
│     └─ PurchaseOrderCardFetchingTest.java
├─ inventory
│  └─ locking
│     ├─ InventoryOptimisticLockingIT.java
│     └─ InventoryPessimisticLockingIT.java
└─ labsupport
   ├─ HibernateStats.java
   ├─ ConcurrencySupport.java
   └─ JpaTestSupport.java

Remarquez une nuance : le dossier labsupport dans les tests est acceptable s’il contient des assistants transparents, et non de la « magie cachée ». Nous ne voulons pas cacher Hibernate aux étudiants sous le tapis. Au contraire, nous voulons rendre les étapes mécaniques (comme clear + reread) répétables et non oubliables, tout en restant compréhensibles.

Les noms de tests comme contrat

Appeler un test testSave(), c’est comme étiqueter une boîte « affaires ». Formellement, ce n’est pas faux, mais quand vous avez urgemment besoin de la télécommande de la climatisation, un tel marquage provoque une tristesse philosophique. Dans une suite de régression ORM, le nom du test doit répondre à la question : quel contrat protégeons-nous ?

Dans notre cours, nous fixons souvent des contrats comme « le read-case doit tenir en 1 SQL », « après un bulk, un clear() est nécessaire », « getReferenceById() ne doit pas être vérifié comme un objet normal ». Si le test est nommé d’après ce sens, il se documente lui-même et devient un artefact lisible du projet, pas simplement « une vérification pour cocher une case ».

Exemple de bon nom de méthode de test (avec des underscores – c’est un style souvent plus lisible en Java que le CamelCase à trois propositions) :

import org.junit.jupiter.api.Test; 

class NamingExampleTest {

    @Test
    void findCardById_loadsWithSingleQuery() {
        // Contrat : la lecture d’une carte doit tenir en une seule requête SQL
    }

    @Test
    void bulkRename_requiresClearBeforeReload() {
        // Contrat : après une opération bulk, le contexte de persistance doit être vidé avant la vérification
    }

    @Test
    void merge_returnsManagedCopy_notSameInstance() {
        // Contrat : merge retourne une copie gérée, pas l’objet détaché original ressuscité
    }
}

L’idée est simple : le test se lit comme une assertion. Vous ne décrivez pas le « comment » (c’est dans le corps du test), vous décrivez « ce qui doit être vrai ». C’est particulièrement important pour l’ORM, car le « comment » est souvent non évident, alors que le « quoi » est votre contrat.

3. Fixtures : minimum et déterminisme

Une fixture dans les tests ORM n’est pas « versons beaucoup de données pour ressembler à la production ». C’est presque toujours un piège. Plus il y a de données, plus il y a de croisements aléatoires, plus le risque que le test dépende de l’ordre dans lequel vous avez sauvegardé ou de l’ID « tombé » aujourd’hui. Hibernate est déjà assez magique sans y ajouter la magie des fixtures.

Le style pratique pour une suite de régression est le suivant : chaque test crée exactement les entités nécessaires à ce contrat, et les nomme de manière à comprendre pourquoi elles existent. « Produit avec SKU en double », « commande avec deux lignes », « stock en conflit de version ». Si vous voyez dans un test Product p1 = new Product(); p1.setSku("X"); – c’est ennuyeux et facile à confondre. Si vous voyez createProduct("SKU-DUP-1", "Souris") – le cerveau comprend mieux.

L’une des approches les plus « légères » est une petite fabrique de données de test dans le package de test. Elle ne doit pas être intelligente, elle n’a pas besoin de connaître tout le domaine. Son but est de rendre les tests lisibles.

import com.example.commerce.catalog.entity.Product;

final class CatalogFixtures {

    static Product product(String sku, String name) {
        // Important : les données de test doivent être « parlantes » (sku et name non aléatoires)
        Product p = new Product();
        p.setSku(sku);   // Par exemple : "SKU-DUP-1"
        p.setName(name); // Par exemple : "Souris"
        return p;
    }

    private CatalogFixtures() {
        // Classe utilitaire : interdire la création d'instances
    }
}

Oui, cela ne fait que quelques lignes – et c’est bien. Si la fabrique devient une « bibliothèque de construction de monde », vous perdez à nouveau la transparence : le test dépend de détails cachés, pas d’un scénario explicite.

Une question revient souvent : « Peut-on utiliser @Sql au lieu de créer des entités dans le test ? » Oui, mais avec précaution. @Sql est utile lorsque vous testez précisément la visibilité SQL (contraintes de schéma, forme spécifique de requête, particularités du soft delete) et que vous voulez contrôler les données au niveau des lignes. Cependant, pour de nombreux scénarios ORM (merge/flush/dirty checking), créer les entités via le repository donne un contexte plus « naturel », car vous suivez le même chemin que l’application.

4. Helpers sans magie

Dans les tests ORM, il y a trois actions que vous allez faire constamment : synchroniser les changements avec la base de données, retirer les objets gérés du contexte de persistance, et re-lire les données. Si vous ne rendez pas ces étapes conscientes, les tests commenceront à se mentir à eux-mêmes : vous vérifierez l’état d’un objet en mémoire en pensant avoir vérifié la base.

C’est pourquoi un petit helper est normal. Mais sa limite est la suivante : il doit être si simple qu’un étudiant puisse le lire une fois et ne plus avoir peur.

Par exemple, le « classique du genre » – flushAndClear() :

import jakarta.persistence.EntityManager;

final class JpaTestSupport {

    static void flushAndClear(EntityManager em) {
        // flush : pousser les changements vers la base (au niveau SQL)
        em.flush();
        // clear : vider le contexte de persistance pour que le re-read aille réellement en base, pas dans le cache de 1er niveau
        em.clear();
    }

    private JpaTestSupport() {
        // Classe utilitaire : interdire la création d'instances
    }
}

Et son utilisation dans le test devient lisible : « sauvegarder → flushAndClear → reload ». Ce n’est pas de la magie, c’est simplement l’extraction d’un rituel répétitif.

Un autre helper utile est le « rechargement d’entité ». Il est important ici de ne pas en faire une « méthode universelle pour tous les cas ». Mieux vaut le rendre concret : rechargement d’un produit, rechargement d’une commande – et qu’il utilise le repository, pour que le test montre explicitement comment nous lisons les données.

import com.example.commerce.catalog.entity.Product;
import com.example.commerce.catalog.repository.ProductRepository;
import jakarta.persistence.EntityManager;

final class ProductReload {

    static Product reload(EntityManager em, ProductRepository repo, Long id) {
        // Important : avant la lecture, vider le contexte de persistance,
        // sinon on risque d’obtenir le même objet géré depuis le cache de 1er niveau
        em.clear();

        // Lire via le repository pour que le test montre « comment exactement » on obtient les données
        return repo.findById(id).orElseThrow();
    }

    private ProductReload() {
        // Classe utilitaire : interdire la création d'instances
    }
}

Ces helpers de rechargement vivent généralement à côté des tests d’une feature spécifique, tandis que dans le labsupport général, il vaut mieux ne mettre que des choses vraiment communes comme HibernateStats, ConcurrencySupport et JpaTestSupport.

Notez que nous avons délibérément laissé em.clear() à l’intérieur du helper, car c’est une action mécanique, mais nous n’avons pas caché le fait de re-lire : la signature montre que nous faisons un reload via le repository. Si le helper commence à faire « à la fois l’opération bulk, la purge, la vérification et l’impression des logs », ce n’est plus un helper, mais un mini-framework qui rend les tests opaques.

5. Matrice de la suite de régression

Quand les tests deviennent nombreux, un nouveau danger apparaît : couvrir dix variantes de fetching pour le catalogue et oublier les effets de bord bulk parce que « on n’a pas eu le temps ». Pour éviter cela, il est pratique d’avoir en tête une matrice minimale : quelles features du projet doivent avoir quels types de contrats ORM.

Faisons-le sous forme de tableau. Un tableau est un bon compromis : c’est structuré et cela ne se transforme pas en liste infinie.

Package de feature Ce que nous protégeons par des tests Exemple de contrat
catalog fetching / projections / visibilité soft delete « la fiche produit se charge sans N+1 », « la liste lit une projection, pas une entité », « un produit soft-deleted n’est pas visible via findById »
catalog.bulk effets de bord bulk / contexte de persistance obsolète « après un bulk update, l’objet géré reste obsolète jusqu’à clear() »
orders comportement de flush / sémantique de merge / fetching de carte de commande « le changement de statut est visible après flush+clear », « merge() retourne une copie gérée », « le chargement détaillé d’une commande ne fait pas de requêtes supplémentaires »
inventory verrouillage (optimiste/pessimiste) « une réservation concurrente provoque un conflit de version », « le verrou pessimiste respecte le timeout/le blocage correctement »

Remarquez : ce n’est pas « vérifier tout ce qui existe ». C’est un ensemble minimal suffisant pour garder le cap dans la réalité. Parce que si vous ne testez pas le contexte de persistance obsolète après un bulk, vous êtes presque assuré d’obtenir un jour « en cours de transaction, les données ne sont pas les bonnes », ce qui est l’un des bugs les plus désagréables : il ressemble à de la magie.

6. Tests concurrents et niveaux de contexte

@DataJpaTest reste le format par défaut pour la plupart des régressions ORM. Mais à ce stade, une exception apparaît déjà : un test concurrent ne concerne pas seulement le mapping JPA et la requête, mais aussi deux transactions indépendantes, parfois au niveau de la méthode de service.

Par conséquent, une stratégie normale pour la suite est : tout ce qui concerne repository/query/mapping vit dans @DataJpaTest, tandis que tout ce qui concerne « deux transactions concurrentes pour une même ligne » peut vivre dans des tests de niveau @SpringBootTest, mais sans la couche web (qui n’est pas nécessaire). Il est important de ne pas confondre l’objectif : nous nous intéressons toujours à la couche de persistance, mais parfois il est plus pratique de la tester via le service, car c’est le service qui définit la limite de transaction.

Dans ce cas, la structure des dossiers reste la même « par feature ». Par exemple, inventory/locking est un endroit logique pour les scénarios optimistes/pessimistes. Et c’est même pratique pédagogiquement : vous ouvrez le package inventory.locking et comprenez que « tout ce qui concerne la concurrence des stocks » est ici.

7. Profils, statistiques et exécution

Un ensemble de tests s’écrit facilement. Le plus difficile est de le faire fonctionner de manière stable, sans dépendre de l’humeur de l’environnement. Pour une suite de régression ORM, deux facteurs sont importants : l’observabilité et un environnement d’exécution identique.

L’observabilité signifie que si vous testez le nombre de requêtes, vous devez disposer d’un outil qui compte ces requêtes. Dans un projet pédagogique, cela peut se faire facilement via HibernateStats – ce petit helper basé sur les statistiques Hibernate. Dans l’environnement de test, il est logique d’activer explicitement les statistiques (via un profil de test), pour éviter qu’un développeur les ait activées et un autre non, et que les tests se comportent différemment.

Exemple de configuration minimale dans application-test.yml (un extrait, sans fanatisme) :

spring:
  jpa:
    properties:
      hibernate:
        # Observabilité : activer les statistiques si les tests vérifient le nombre de requêtes SQL
        generate_statistics: true

Le deuxième facteur est un lancement prévisible. Même si vous ne construisez pas une « plateforme énorme », il est utile au moins de convenir que la suite se lance avec une seule commande (./gradlew test) et que les tests ne nécessitent pas de « maintenant, lancez la base et attendez 40 secondes » manuellement. Dans le cours, nous nous orientons vers un environnement PostgreSQL, donc généralement soit une base locale est déjà levée, soit on utilise une approche conteneurisée. Mais indépendamment de la mécanique, l’objectif est le même : un test doit échouer à cause du comportement ORM, pas parce que « quelqu’un a oublié de lancer Docker ».

8. Erreurs typiques dans l’organisation d’une suite de régression ORM

Erreur n°1 : une seule classe de test géante « pour toute la couche de données ».
Cela commence innocemment : « bon, pour l’instant on a peu de tests ». Au bout d’une semaine, la classe gonfle, elle contient différentes features du domaine et différents types de risques, puis toute modification devient un jeu « trouve l’endroit où ça a cassé ». Il est bien plus serein de garder les tests petits et de les répartir par packages de feature et par type de contrat.

Erreur n°2 : des fixtures “comme en production” parce que c’est plus réaliste.
Une grosse fixture tue presque toujours la lisibilité. Le test se met à dépendre de croisements aléatoires de données, de l’ordre d’insertion, d’unicités implicites. Pour une suite de régression, le déterminisme est plus important. Le réalisme dans le monde ORM ne s’obtient pas par le nombre de lignes, mais par le fait de tester sur une vraie base de données et avec les contraintes réelles du schéma.

Erreur n°3 : vérifier le résultat via le même objet géré et croire que c’est une vérification de la base.
Hibernate sait être convaincant : vous avez modifié un champ, relu le champ – tout correspond, le test est vert. Mais cela peut n’être que le cache de premier niveau, pas une véritable écriture en base. Si vous voulez vérifier la « visibilité en base », vous avez presque toujours besoin d’un flush() et d’un clear(), puis d’un re-read via le repository.

Erreur n°4 : « des helpers pour des helpers » qui cachent toute la mécanique.
Parfois on a envie de créer une belle classe de base AbstractJpaTest qui « fait tout toute seule ». Au final, le test se réduit à deux lignes, mais cesse d’être pédagogique et ingénieurial : on ne voit plus où est la transaction, où est le flush, où est le clear, où est le re-read. Les helpers doivent supprimer la routine, mais pas cacher le sens.

Erreur n°5 : les tests concurrents basés sur sleep() et l’espoir de chance.
Si vous voulez reproduire un conflit optimiste, il faut coordonner le démarrage parallèle des transactions, sinon le test deviendra flaky : parfois le conflit aura lieu, parfois non. Un test flaky est pire que l’absence de test, car il apprend à l’équipe à ignorer la couleur rouge.

1
Mission
Hibernate deep-dive, niveau 28, leçon 4
Bloqué
Support minimal `labsupport` pour une suite de tests de régression ORM
Support minimal `labsupport` pour une suite de tests de régression ORM
1
Mission
Hibernate deep-dive, niveau 28, leçon 4
Bloqué
Décomposez la suite de régression ORM par paquets fonctionnels et responsabilités
Décomposez la suite de régression ORM par paquets fonctionnels et responsabilités
1
Étude/Quiz
Tests ORM, niveau 28, leçon 4
Indisponible
Tests ORM
Détecter les bugs ORM dans les tests
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION