CodeGym /Cours /Spring Test /Relations Article en JPA

Relations Article en JPA

Spring Test
Niveau 17, Leçon 0
Disponible

1. Tests séparés des relations JPA

Jusqu’à présent, nous avons abordé le comportement des requêtes (query behavior), la pagination et le choix du chemin de données (data-path), mais cela ne suffit pas. Même un findBy... parfaitement écrit ne sauvera pas la mise si le graphe d’entités lui-même se comporte étrangement : la relation peut être persistée au mauvais endroit, se comporter de manière imprévisible lors d’une modification, être chargée paresseusement avec des surprises, ou perdre une mise à jour concurrente lors d’une écriture parallèle. C’est pourquoi nous commençons par la question la plus fondamentale : comment prouver honnêtement que les relations Article ↔ Category / ArticleAttachment sont réellement stockées en base de données comme nous l’attendons.

Lorsqu’on commence à écrire du JPA, on a très envie de croire que les annotations sont une sorte de tatouage magique : on dessine @ManyToOne et l’Univers garantit que la relation sera toujours persistée et toujours lue correctement. Malheureusement (et heureusement pour notre métier), JPA n’est pas de l’astrologie mais de l’ingénierie. Une relation peut être décrite « joliment » mais se comporter étrangement : ne pas être persistée, l’être au mauvais endroit, générer une table inutile, devenir inopinément null, ou simplement « vivre uniquement en mémoire ».

Dans ContentHub, les relations ne sont pas une partie décorative du modèle. Article possède une catégorie (Category) et des pièces jointes (ArticleAttachment). Cela impacte des scénarios réels : l’affichage public est souvent filtré par catégorie, et les scénarios editor/admin manipulent constamment les pièces jointes. Si le mappage est cassé, ni une couche service parfaite ni des contrôleurs somptueux ne vous aideront : les données seront soit écrites de travers, soit lues de travers.

Dans ce cours, nous appliquerons une règle simple et honnête : une relation est considérée comme fonctionnelle non pas quand « le champ n’est pas null », mais quand nous avons écrit les entités en base, vidé le contexte et les avons relues — et obtenu exactement la liaison attendue. Autrement dit, nous testons le comportement du système de persistance, et non un état favorable d’objets en mémoire vive.

2. Schéma des données : clés étrangères

Avant d’écrire des tests sur les relations, il est utile de redescendre un instant du niveau Java au niveau base de données. En JPA, on peut discuter très longtemps des « relations entre objets », mais la base de données voit les choses plus simplement : « j’ai des tables et j’ai des clés étrangères ». Par conséquent, un bon test de relation consiste toujours à vérifier que la clé étrangère se trouve dans la bonne colonne et pointe vers le bon enregistrement (même si nous vérifions cela via les repositories).

Schématiquement, nos relations se présentent comme suit :

erDiagram
  %% Schéma des relations et clés étrangères (celui qui stocke la FK est le côté propriétaire)
  CATEGORY ||--o{ ARTICLE : "article.category_id"
  ARTICLE  ||--o{ ARTICLE_ATTACHMENT : "attachment.article_id"

  CATEGORY {
    bigint id
    varchar code
    varchar name
  }

  ARTICLE {
    bigint id
    varchar title
    bigint category_id
  }

  ARTICLE_ATTACHMENT {
    bigint id
    bigint article_id
    varchar original_filename
  }

Il est important de saisir deux idées. La première : l’article stocke une référence à la catégorie via une colonne du type category_id. Cela signifie que c’est la table article qui contient la clé étrangère vers category. La deuxième : la pièce jointe stocke une référence à l’article via article_id, donc la clé étrangère se trouve dans la table article_attachment.

Pour simplifier avec une analogie domestique, la clé étrangère n’est pas une « amitié » entre objets, mais une « adresse de domiciliation ». Et celui qui stocke l’adresse est considéré en base comme le porteur de la relation. Nous traduirons cela plus tard en terme JPA de « côté qui stocke la clé étrangère » (owning side) — et nous comprendrons pourquoi un test de relation doit impérativement en tenir compte.

3. Côté propriétaire et côté inverse en JPA

En JPA, une relation a presque toujours deux faces : l’une « réelle » (celle qui écrit la clé étrangère), et l’autre « miroir » (celle qui expose simplement la relation dans le modèle objet). Les débutants pensent souvent que si en Java nous avons deux champs qui « se référencent mutuellement », JPA synchronise tout automatiquement. En pratique, JPA ne synchronise que ce que vous avez explicitement et correctement configuré et modifié.

Article → Category (@ManyToOne)

Pour la relation « plusieurs articles vers une seule catégorie », le côté propriétaire est généralement Article. En effet, c’est Article qui porte @ManyToOne et c’est sa table qui contient la colonne category_id. Voici un extrait d’entité (nous réduisons tout ce qui n’a pas trait à la relation) :

import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;

@Entity
class Article {

    // Côté propriétaire : c'est ce champ qui sera écrit dans la colonne FK article.category_id
    @ManyToOne(fetch = FetchType.LAZY, optional = false) // LAZY : ne pas charger Category immédiatement sans besoin
    @JoinColumn(name = "category_id", nullable = false)  // Colonne FK dans la table article
    private Category category;
}

Il n’y a pas de « magie » ici : si vous persistez un Article avec une catégorie, JPA écrit category_id dans la table article. Si vous « perdez » la catégorie lors de la persistance, elle sera également perdue en base.

Article ↔ ArticleAttachment (@OneToMany et @ManyToOne)

Avec les pièces jointes, c’est plus intéressant : nous voulons généralement à la fois la liste des pièces jointes d’un article et la référence à l’article dans chaque pièce jointe. Mais la clé étrangère, selon le schéma, se trouve dans la table des pièces jointes, donc le côté propriétaire est ArticleAttachment (la partie qui porte @ManyToOne).

Extrait de ArticleAttachment :

import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;

@Entity
class ArticleAttachment {

    // Côté propriétaire : la FK se trouve dans la table article_attachment (colonne article_id)
    @ManyToOne(fetch = FetchType.LAZY, optional = false) // LAZY : l'article ne sera chargé qu'en cas d'accès
    @JoinColumn(name = "article_id", nullable = false)   // Colonne FK dans la table article_attachment
    private Article article;
}

La liste du côté d’Article ressemble généralement à ceci (c’est le côté inverse, car il utilise mappedBy) :

import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;

// Côté inverse : c'est un simple "miroir", JPA se réfère au champ ArticleAttachment.article
@OneToMany(mappedBy = "article") // IMPORTANT : nom du champ dans ArticleAttachment, pas le nom de la colonne
private List<ArticleAttachment> attachments = new ArrayList<>(); // Initialisation pour éviter un NPE

La signification de mappedBy = "article" est très pragmatique : « ne crée pas ta propre colonne/table pour la relation, regarde le champ article dans ArticleAttachment, c’est là que tout est réel ». Oublier mappedBy ou l’écrire avec une erreur (par exemple "artcle" — bonjour la faute de frappe) peut produire des effets très « amusants » : d’une table de jointure superflue à une relation qui semble fonctionner en mémoire mais qui en réalité ne se fixe pas en base comme vous l’attendez.

4. Modèle de test de relation

En soi, les tests sur les relations ne consistent pas à « écrire le plus d’assertions possible », mais à bien poser l’expérience. Nous devons placer JPA dans une situation où il doit aller en base, et non nous laisser contempler un objet déjà géré (managed) qui garde accidentellement une référence en mémoire.

Voici le modèle de scénario de base à retenir presque comme une table de multiplication (oui, c’est ennuyeux, mais vous vous en remercierez plus tard) :

flowchart LR
  %% Idée : forcer JPA à aller en BDD et à retirer les objets gérés de la mémoire
  A[persist fixture] --> B[flush]
  B --> C[clear]
  C --> D[reload from repository]
  D --> E[assert relation]

Le sens des étapes peut se retenir ainsi : d’abord on crée un jeu de données minimal (fixture), puis on force JPA à exécuter le SQL (flush), ensuite on « oublie » tout ce qui était en mémoire (clear), et seulement après on relit et on vérifie.

Il est souvent utile de présenter cela sous forme de mini-« tableau des étapes » pour que les étudiants (et vous-même) y voient plus clair :

Étape Notre action Ce que cela prouve
Initialisation persist(...) Nous avons créé les données dans le contexte JPA
Écriture en base flush() Le SQL est réellement exécuté
Vidage du contexte clear() Nous ne regardons plus les anciens objets gérés (managed)
Vérification findById(...) Nous lisons depuis la base, pas depuis la mémoire

Important : nous n’essayons pas ici de « vaincre JPA » ou de « casser Hibernate ». Nous voulons simplement que le test réponde à une question honnête : la relation sera-t-elle persistée et lue correctement après un aller-retour réel vers la base ?.

5. Test Article → Category

Passons maintenant à la pratique sur la première relation. Un article a exactement une catégorie, et c’est un point de départ très pratique : une clé étrangère, une vérification, un minimum de bruit. Dans un test de données, nous créons généralement une catégorie, puis un article avec cette catégorie, nous vidons le contexte et nous relisons l’article.

Supposons que nous disposions déjà dans le test de TestEntityManager em et de ArticleRepository articleRepository (c’est la configuration standard avec @DataJpaTest). Le corps du test pourrait alors ressembler à ceci :

import static org.assertj.core.api.Assertions.assertThat;

// 1) Préparer un jeu de données minimal (catégorie + article)
Category java = em.persist(new Category("java", "Java"));
Article article = em.persist(new Article("JPA basics", java));

// 2) Forcer l'écriture en base (sans cela on risquerait de ne tester que le contexte de persistance)
em.flush();

// 3) Vider le contexte pour ne pas lire les "anciens" objets gérés depuis la mémoire
em.clear();

// 4) Relire depuis le repository (donc depuis la base) et vérifier le champ "ancre" de la relation
Article reloaded = articleRepository.findById(article.getId()).orElseThrow();
assertThat(reloaded.getCategory().getCode()).isEqualTo("java");

Remarquez le style des assertions. Nous ne comparons pas les catégories entières via equals, ne vérifions pas vingt champs, n’organisons pas un « examen sur tout le manuel JPA ». Nous prenons une seule ancre — Category.code, qui est stable et porteuse de sens dans le domaine. Si, après l’aller-retour, l’article pointe vers une catégorie avec le code "java", alors la relation externe a été correctement persistée et lue.

Un autre point pratique : dans un test, il est commode de vérifier précisément ce qui est important pour le domaine. Dans ContentHub, une catégorie n’est pas simplement un « nom textuel ». Souvent, dans les systèmes réels, les catégories vivent comme un dictionnaire de référence, et code est utilisé dans les filtres, les liens, les migrations, les scénarios de test. C’est pourquoi code est un bon candidat pour l’assertion.

6. Test Article ↔ Attachment dans les deux sens

Avec les pièces jointes, on rencontre presque toujours la même histoire : le développeur vérifie « l’article a une liste de pièces jointes », se réjouit, puis découvre que la pièce jointe n’est en fait pas du tout liée à l’article en base, ou est liée à la mauvaise. C’est pourquoi, pour la relation Article ↔ ArticleAttachment, il est utile de penser ainsi : c’est une seule relation, mais deux « faits observables » distincts, et il est préférable de prouver les deux.

La pièce jointe appartient à l’article : côté propriétaire

Commençons par le côté propriétaire — ArticleAttachment.article. Nous créons un article, créons une pièce jointe en lui associant l’article, persistons, vidons le contexte et relisons la pièce jointe. Ensuite, sur la pièce jointe relue, nous vérifions article.id.

import static org.assertj.core.api.Assertions.assertThat;

// 1) Préparer les entités (dans l'exemple, on suppose que Category java existe déjà et est valide pour Article)
Article article = em.persist(new Article("JPA basics", java));
ArticleAttachment att = em.persist(new ArticleAttachment(article, "guide.pdf"));

// 2) Écrire sur le disque et retirer les objets gérés de la mémoire
em.flush();
em.clear();

// 3) Relire la pièce jointe et vérifier le sens de la FK : article_id doit pointer vers le bon article
ArticleAttachment reloaded = attachmentRepository.findById(att.getId()).orElseThrow();
assertThat(reloaded.getArticle().getId()).isEqualTo(article.getId());

Pourquoi est-ce important ? Parce que cela prouve que la colonne article_id dans la table des pièces jointes contient la bonne valeur. C’est un fait « en béton armé » au niveau de la base, et pas simplement « dans l’objet Java, il y avait une référence quelque part ».

Ici, je maintiens délibérément les champs de la pièce jointe au minimum. Dans un projet réel, ArticleAttachment aura plus de métadonnées (content type, taille, stored filename, etc.), mais pour vérifier la relation, il suffit que l’entité soit valide et qu’elle soit persistée. Nous ne testons pas la « qualité du stockage », nous testons que article_id pointe là où il faut.

L’article voit ses pièces jointes : côté inverse

Passons maintenant au second fait : l’article relu doit contenir dans sa collection les pièces jointes attendues. Ici, on fait souvent l’erreur de ne vérifier que size(). La taille est utile, mais elle ne dit pas quelles sont exactement les pièces jointes présentes. Il est préférable d’ajouter une vérification sur un champ porteur de sens, par exemple originalFilename.

import static org.assertj.core.api.Assertions.assertThat;

// Relire l'article (après flush/clear) et vérifier que le côté inverse est bien chargé depuis la base
Article reloaded = articleRepository.findById(article.getId()).orElseThrow();

assertThat(reloaded.getAttachments())
        // Ne pas vérifier seulement la taille, mais des données concrètes (champ ancre de la pièce jointe)
        .extracting(ArticleAttachment::getOriginalFilename)
        .containsExactly("guide.pdf");

Cette vérification est intéressante car elle est « un peu plus stricte que la taille ». Si le mappage commence accidentellement à attacher des pièces jointes « étrangères » ou si la collection est vide, le test échouera de façon plus explicite. En outre, nous ne dépendons pas de détails internes comme l’ordre d’insertion dans la liste ou l’id spécifique de la pièce jointe (l’id peut être généré de n’importe quelle façon, ce n’est pas ce pour quoi nous testons).

Et oui, c’est là qu’apparaît l’avantage pratique d’un petit jeu de données : avec une seule pièce jointe, containsExactly("guide.pdf") se lit comme une phrase humaine. Si vous créez 27 pièces jointes « au cas où », le test se transforme en une liste de noms de fichiers longue comme un mémoire, et il en devient peu utile.

7. Nuances utiles

Petit jeu de données comme outil de diagnostic

Il y a un vieux réflexe (néfaste) : plus il y a de données dans un test, plus il serait « réaliste ». Dans la couche de données, c’est souvent l’inverse. Un gros jeu de données ne renforce pas le test, il le rend moins diagnosticable. Quand le test échoue, vous regardez une énorme initialisation et ne comprenez pas quelle partie des données est cruciale pour le défaut : la catégorie ? l’article ? la troisième pièce jointe ? un statut aléatoire ? ou avez-vous simplement oublié flush() ?

Pour un test de relation, trois objets suffisent presque toujours : un Category, un Article, un ArticleAttachment (parfois deux si vous voulez vérifier l’ordre ou la multiplicité). Un tel ensemble vous donne une image extrêmement claire : si la relation est persistée, elle l’est ; si elle est cassée, vous voyez rapidement où.

Un avantage supplémentaire d’un petit jeu de données est qu’il vous oblige à formuler des assertions sur le sens. Par exemple, « la catégorie de l’article relu a le code java » est une attente compréhensible, orientée métier. Alors que « la liste de l’article relu a une taille de 17 » ressemble davantage à un test de mécanique interne qu’à un test du comportement du modèle de données.

Au final, nous obtenons un principe soigné : dans un test de données, il faut créer exactement autant de données que nécessaire pour que le défaut se manifeste. Tout le reste n’est que bruit, qui complique la lecture et augmente la probabilité que le test échoue pour des raisons sans rapport avec ce que vous vouliez réellement vérifier.

Assertions : ID et champs d’ancrage

Lorsqu’on teste des relations, on a très envie de faire une vérification « jolie » : comparer entièrement un Category ou un ArticleAttachment avec l’objet attendu. Dans la pratique, cela mène presque toujours à des problèmes. L’entité peut avoir des champs qui changent automatiquement (createdAt, updatedAt), des champs qui ne sont pas chargés immédiatement, ou simplement un equals/hashCode qui n’est pas implémenté comme vous l’attendez (parfois pas implémenté du tout, ce qui est normal).

C’est pourquoi, dans les tests de données, nous choisissons généralement un petit ensemble de champs qui servent d’« ancres ». Pour Category, c’est souvent code, car il est stable et porteur de sens. Pour Article, cela peut être slug (s’il existe et est important), mais aujourd’hui nous ne parlons pas de slug. Pour les pièces jointes, c’est originalFilename, car c’est ce qui vient réellement de l’utilisateur et doit être lié à un article spécifique.

Si vous devez prouver l’appartenance, l’ancre la plus directe et la plus honnête est l’id de l’entité liée. C’est ainsi que la base conçoit la relation : la colonne article_id contient un nombre spécifique. Par conséquent, l’assertion reloadedAttachment.getArticle().getId() == articleId est la vérification du sens la plus proche de la base.

Et un autre point qui rend souvent les tests plus « propres » : il est préférable d’extraire les valeurs d’une collection via extracting plutôt que d’écrire des boucles manuelles. Ainsi l’assertion reste courte et lisible, au lieu de se transformer en mini-programme à l’intérieur du test. Un test doit rester un texte que les humains lisent, pas un « second service » qui effectue des calculs complexes.

8. Erreurs typiques lors du test des relations JPA

Erreur n° 1 : tester la relation sans flush() et clear().
C’est le piège le plus fréquent, et il est d’autant plus sournois que le test peut être vert. Vous créez un article, lui assignez une catégorie, persistez, puis vérifiez immédiatement article.getCategory(). Bien sûr, ce n’est pas null — vous venez vous-même d’y mettre la référence. Mais vous avez testé la mémoire, pas la base. Dès qu’un aller-retour flush → clear → reload apparaît dans le test, le test commence à prouver réellement le comportement de la couche de persistance.

Erreur n° 2 : ne tester que le côté inverse de la collection et ignorer le côté propriétaire.
Vérifier article.getAttachments().size() seul ne prouve pas que la clé étrangère article_id dans la table des pièces jointes est correcte. Dans le pire des cas, la collection pourra sembler remplie dans le contexte de persistance, mais après relecture elle se révélera vide ou imprévisible. C’est pourquoi, pour Article ↔ ArticleAttachment, il est utile de vérifier séparément reloadedAttachment.getArticle().getId() — c’est la preuve directe d’une clé étrangère correcte.

Erreur n° 3 : jeu de données trop gros « au cas où ».
Quand l’initialisation comprend cinq catégories, dix articles, vingt pièces jointes et deux statuts « pour le réalisme », le test commence à ressembler à un jeu vidéo : on ne comprend pas ce qui s’est passé, mais « quelque chose ne va pas ». Un test de relation a besoin au plus d’un exemplaire de chaque entité. Moins il y a de données, plus vite vous trouvez la vraie cause de l’échec.

Erreur n° 4 : comparer l’entité entière au lieu de vérifier les ancres sémantiques.
Comparer des objets entiers est un chemin presque garanti vers des tests fragiles. Une entité peut avoir des champs temporels, des valeurs auto-générées, des références lazy ou simplement des différences dans la manière dont JPA crée un proxy ou une classe réelle. Il est bien plus robuste de vérifier l’id de l’entité liée et un ou deux champs du domaine comme Category.code ou ArticleAttachment.originalFilename.

Erreur n° 5 : mélanger trop de risques dans un seul test.
Si dans un seul test vous essayez de prouver « la relation fonctionne », « la requête filtre correctement », « le tri est déterministe » et « tout cela est en plus rapide », le test devient lourd et difficile à expliquer. Dans ce cours, nous gardons le focus sur un point : nous écrivons une relation, nous la relisons, nous nous assurons qu’elle est la même. Tout le reste, il est préférable de le vérifier dans des tests séparés, afin que chaque test ait une raison d’être claire.

1
Mission
Spring Test, niveau 17, leçon 0
Bloqué
Lien de l'article avec la catégorie après `flush()` et `clear()`
Lien de l'article avec la catégorie après `flush()` et `clear()`
1
Mission
Spring Test, niveau 17, leçon 0
Bloqué
Relation entre une pièce jointe et un article après relecture
Relation entre une pièce jointe et un article après relecture
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION