1. Contexte commun pour les tests au lieu de @Sql local
Lorsque vous écrivez des tests de référentiel, vous souhaitez presque toujours que la base soit « réaliste » au début : certains dictionnaires existent déjà, les contraintes sont activées, les entités de base peuvent être liées. Et c'est là qu'une mauvaise habitude apparaît : chaque test commence à trainer les mêmes morceaux préparatoires, transformant la suite en série « Insérez la catégorie java — saison 12 ». Pour éviter cela, nous introduisons l'idée d'un baseline de test stable.
Imaginez que vous ayez deux classes de tests. Dans la première, vous testez différentes requêtes sur les articles, les tris et la pagination. Dans la seconde, vous testez les relations et les pièces jointes. Dans les deux cas, vous avez besoin d'au moins une ou deux catégories, car Article.category est obligatoire, et de préférence avec des codes compréhensibles pour que le test se lise comme une histoire, pas comme un rapport comptable : « categoryId = 17, parce que c'est comme ça historiquement ».
@Sql résout parfaitement le problème « dans ce test spécifique, j'ai besoin d'une telle table dans tel état ». Mais si le même ensemble de lignes est nécessaire partout, il commence à vivre comme une « dépendance cachée » : on ne se souvient pas toujours où il est préparé, et les nouveaux tests oublient parfois de l'inclure et échouent « pour rien ».
C'est pourquoi nous distinguons deux types de données :
- Données scénaristiques — ce qui est unique au test ou à un petit groupe de tests (c'est le plus souvent @Sql ou une configuration Java via TestEntityManager).
- Données de référence/de base — un fond stable sur lequel s'appuient de nombreux tests (c'est un bon candidat pour les migrations réservées aux tests).
L'idée est simple : si le baseline de test est la « gravité », alors @Sql est le « lancer de balle au bon endroit ». La gravité doit être unique et prévisible.
2. Les migrations réservées aux tests comme couche de données pour les tests
Les migrations réservées aux tests semblent vouloir « tromper » le système : en production c'est une chose, en test c'en est une autre. En réalité, c'est une technique d'ingénierie tout à fait honnête : nous disons que dans l'environnement de test, nous avons besoin d'une couche supplémentaire de données qui aide à écrire des tests plus simples, plus rapides et plus stables. Et oui — cette couche ne doit pas se retrouver dans le jar de production. C'est pourquoi elle vit dans src/test/resources.
Un point important : une migration réservée aux tests n'est pas une alternative à Flyway, c'est le même Flyway, juste un autre ensemble de fichiers sur le classpath. Lorsque @DataJpaTest est lancé, Spring Boot démarre un ApplicationContext de test, crée un DataSource, et Flyway applique les migrations. Si des fichiers de migration de src/main/resources se trouvent sur le classpath, ils seront appliqués. Si des migrations de src/test/resources s'y trouvent aussi, elles seront également appliquées — mais seulement dans les tests, car la compilation de production ne les inclut pas.
Cette approche a une propriété très agréable : la migration de test s'exécute avant que JUnit 6 ne commence à exécuter les méthodes de test. De plus, les @DataJpaTest sont généralement transactionnels avec un rollback par défaut. Cela signifie que le « fond de base » sera le même pour tous les tests dans le même contexte, et ne sera pas « dévoré » par l'annulation de transaction d'un test individuel.
Il est utile de garder en tête le schéma suivant :
flowchart TD
%% L'ordre est important : les migrations sont appliquées avant l'exécution des méthodes de test
A["Démarrage de la classe @DataJpaTest"] --> B["L'ApplicationContext de test est créé"]
B --> C["Un DataSource est créé"]
C --> D["Flyway applique les main migrations"]
D --> E["Flyway applique les test-only migrations (si présentes)"]
E --> F["JUnit 6 lance la méthode de test"]
F --> G["Le test fonctionne dans une transaction"]
G --> H["Rollback après le test (par défaut)"]
Autrement dit, les migrations réservées aux tests sont une façon de faire en sorte que le point de départ soit identique et ne dépende pas du fait que l'auteur du test ait pensé à ajouter @Sql ou non.
3. Stockage des migrations réservées aux tests : structure
Tant que vous apprenez, gardez la structure aussi évidente que possible : si Flyway utilise par défaut classpath:db/migration, il est pratique de placer les migrations de test dans src/test/resources/db/migration. Ainsi, pendant les tests, elles se retrouvent sur le classpath et Flyway les trouve automatiquement — sans configuration supplémentaire.
Visuellement, cela ressemble à ceci :
src
├─ main/resources/db/migration
│ ├─ V1__create_categories.sql
│ └─ V2__create_articles.sql
└─ test/resources/db/migration
├─ V900__seed_reference_categories.sql
└─ V901__seed_reference_admin_articles.sql
Dans le jar de production, seuls src/main/resources seront inclus, tandis que src/test/resources resteront dans le classpath de test. C'est honnête et sûr : les tests reçoivent des migrations supplémentaires, et l'application en production n'en a même pas connaissance.
Parfois, les équipes choisissent une option plus « propre » : elles placent les migrations de test non pas dans le dossier commun db/migration, mais dans un dossier séparé comme db/test-baseline, pour que l'on voie immédiatement : « c'est uniquement pour les tests ». Dans ce cas, il faut explicitement ajouter l'emplacement dans la configuration de Flyway.
Par exemple, via le profil de test :
# src/test/resources/application-test.yml
spring:
flyway:
# Inclure explicitement les deux ensembles de migrations : production et baseline de test
locations: classpath:db/migration,classpath:db/test-baseline
Ce n'est pas forcément « mieux », c'est simplement un autre compromis. La première option est plus simple : moins de configuration et moins de risque d'oublier d'inclure. La seconde option est plus explicite au niveau de la structure : on ne mélange pas les migrations « de production » et le « fond de test » dans le même dossier.
Pour le projet pédagogique ContentHub, je commencerais par la norme simple src/test/resources/db/migration et ne séparerait test-baseline dans un emplacement distinct que si le nombre de fichiers devenait vraiment important. Au début, la prévisibilité est plus importante que la beauté architecturale des répertoires.
4. Numérotation et noms des migrations de test
Les migrations Flyway sont ordonnées par version dans le nom du fichier, ce qui est à la fois une superpuissance et une source de drames. Si vous nommez une migration de test V3__seed_categories.sql alors qu'il existe déjà V3__something.sql dans la chaîne principale, Flyway vous fera une démonstration très convaincante de pourquoi il ne faut pas faire ça. C'est pourquoi on éloigne généralement les migrations de test dans une plage de versions distincte.
L'astuce la plus populaire (et très pratique) consiste à utiliser de grands numéros comme V900, V901, V999. Cela fonctionne car vous n'atteindrez presque certainement pas la migration principale 900 dans un projet pédagogique. Et surtout, à la lecture, on voit immédiatement : « ah, 900+ — c'est pour les tests ».
Pour les migrations en général, il est utile de suivre une règle : le nom du fichier doit expliquer le but, pas seulement le fait qu'il existe. Le fichier V900__seed.sql est comme une variable x : techniquement valide, mais communiquer avec cela plus tard sera douloureux. En revanche, V900__seed_reference_categories.sql explique déjà qu'il s'agit de catégories de référence et précisément pour le baseline.
Un petit tableau pour aider à garder de l'ordre dans l'esprit :
| Type de migration | Exemple de nom de fichier | Où il se trouve | Utilité |
|---|---|---|---|
| Migration de schéma principale | V2__create_articles.sql | src/main/resources | Crée/modifie la structure, nécessaire à l'application |
| Migration de peuplement principale (si existante) | V3__seed_categories.sql | src/main/resources | Remplit la base de production avec des données de base |
| Migration baseline réservée aux tests | V900__seed_reference_categories.sql | src/test/resources | Fournit un fond commun pour les tests, ne va pas en production |
| Migration de jeu de données réservée aux tests | V910__seed_published_articles_dataset.sql | src/test/resources | Fournit un ensemble de test pour de nombreux tests, mais pas pour un seul |
Remarque : « baseline réservée aux tests » et « jeu de données réservé aux tests » sont tous deux des migrations réservées aux tests, mais le baseline concerne généralement les dictionnaires et un fond minimal, tandis que le jeu de données peut être un peu plus « riche » si de nombreux tests ont besoin du même ensemble d'enregistrements.
5. Données de référence dans ContentHub : baseline et scénarios
Les données de référence sont ces enregistrements qui ne changent pas à chaque minute, ne « vivent » pas dans un workflow, et sur lesquels il est pratique de s'appuyer comme des constantes. Dans ContentHub, c'est un rôle presque parfait pour Category : elle a un code stable, que l'on retrouve dans l'API, dans les tests, et parfois même dans la logique métier.
Et il est important de ne pas confondre les deux approches.
Si dans vos tests vous vous basez sur des id « aléatoires », dans quelques semaines vous découvrirez que vous lisez le test comme une énigme : « pourquoi la catégorie avec id = 1 est-elle Java ? ». Oui, vous pouvez fixer l'id dans la migration, et c'est parfois pratique, mais une méthode plus robuste consiste à s'appuyer sur une clé naturelle (dans notre cas, code). Alors le test ressemble à un texte normal : « il doit exister une catégorie avec le code java ».
Deuxième idée importante : le baseline doit être petit. Il ne doit pas se transformer en « mini-production ». Dès que vous commencez à y ajouter « bon, mettons 50 articles de plus, 20 pièces jointes et trois scénarios de publication », vous perdez le contrôle. Les tests commencent à dépendre implicitement de données qu'ils ne créent pas eux-mêmes, et on se retrouve dans la situation classique : « le test a échoué parce que le baseline a été modifié, mais personne ne savait qu'il dépendait de cela ».
C'est pourquoi il est préférable de concevoir le baseline comme un ensemble minimal qui rend les tests pratiques, mais qui ne les rend pas magiques. Pour les catégories, cela signifie généralement 2 à 5 enregistrements avec des codes lisibles (java, spring, testing, architecture) et des noms compréhensibles.
Si vous avez besoin d'un ensemble de données « riche », soit c'est une migration de jeu de données distincte (et vous spécifiez explicitement que cet ensemble est une convention pour de nombreux tests), soit c'est @Sql/Java-setup au niveau d'un test spécifique. Mais le baseline, je le répète, doit rester ennuyeux. Un baseline ennuyeux est un compliment, pas une insulte.
6. Exemples : migration et contrat de test
Migration avec les catégories de référence
Maintenant, rassemblons tout cela dans un exemple concret et très appliqué pour ContentHub. Nous voulons que dans chaque @DataJpaTest nous ayons garantie des catégories avec des codes compréhensibles. Faisons-le via une migration réservée aux tests.
Que le fichier se trouve ici :
src/test/resources/db/migration/V900__seed_reference_categories.sql
Et qu'il contienne un minimum de lignes pour être lisible :
-- V900__seed_reference_categories.sql
-- Catégories de référence pour le baseline de test : les tests s'appuient sur le code, pas sur l'id
insert into categories(code, name) values ('java', 'Java');
insert into categories(code, name) values ('spring', 'Spring');
Il y a ici deux nuances importantes.
Première nuance : nous insérons par code, pas par id. Cela signifie que les tests n'ont pas besoin de connaître l'id numérique d'une catégorie et ne cassent pas lors d'un changement de séquence. Deuxième nuance : dans l'exemple canonique de baseline, on utilise un simple insert, sans on conflict. Pour une base de test propre, c'est plus honnête : si un enregistrement existe déjà, cela signifie qu'un même dictionnaire a deux propriétaires, et il faut corriger cela dans les migrations, pas le contourner silencieusement. Si le projet vit consciemment uniquement sur PostgreSQL, une variante défensive avec on conflict (code) do nothing est possible, mais c'est une protection locale, pas une recette universelle.
Et bien sûr, l'idée même de s'appuyer sur code suppose l'unicité de ce champ. Si le projet n'a pas de contrainte d'unicité sur Category.code, les données de référence deviennent « troubles » — et les tests, ainsi que l'API, en souffrent. Par conséquent, une contrainte unique sur Category.code n'est pas un caprice des tests, mais un invariant normal du domaine.
Contrat de test sur le baseline
Le problème le plus fréquent avec les données de référence est que les gens commencent à les considérer comme « allant de soi ». Tant que le projet est petit, tout va bien. Ensuite, quelqu'un modifie le seed, quelqu'un renomme un code, quelqu'un décide « mettons java-core au lieu de java », et soudain la moitié des tests de données échouent.
Pour que le baseline ne soit pas une magie cachée, il est utile d'avoir au moins un petit test qui dit explicitement : « notre environnement de test suppose que la catégorie java existe ». Cela ne signifie pas qu'il faut vérifier toutes les catégories à chaque fois, mais un contrat de test sur le baseline est comme un panneau « Ne pas nourrir les tests avec des données aléatoires ».
Si vous avez un CategoryRepository avec une méthode findByCode, le test est très compact et lisible :
package com.example.contenthub.repository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
class CategoryReferenceDataDataJpaTest {
@Autowired
private CategoryRepository categoryRepository; // Le référentiel est nécessaire pour rechercher par clé naturelle (code)
@Test
void shouldContainJavaCategoryInTestBaseline() {
// Ce test est un contrat sur le baseline : la catégorie avec code="java" doit exister dans tous les @DataJpaTest
Category category = categoryRepository.findByCode("java").orElseThrow(); // Si le baseline est cassé, on échoue immédiatement et honnêtement
// On ne vérifie pas « il y a exactement N catégories dans la base », mais une convention spécifique sur laquelle les tests s'appuient
assertThat(category.getName()).isEqualTo("Java");
}
}
Remarquez le style : le test ne vérifie pas « il y a exactement deux catégories dans la base » (c'est trop rigide), mais vérifie la convention clé : la catégorie avec le code java existe et a le nom attendu. Si vous ajoutez par la suite une troisième catégorie dans le baseline, le test s'en moque. Mais si vous cassez accidentellement le code ou la migration, il échouera immédiatement et honnêtement.
Si le référentiel n'existe pas (ou si vous voulez souligner qu'il s'agit vraiment de l'état de la base), vous pouvez faire la même chose avec TestEntityManager, mais alors vous devrez connaître id. C'est pourquoi pour les données de référence, findByCode est généralement plus pratique et plus proche du domaine.
7. Erreurs typiques avec les migrations réservées aux tests et les données de référence
Erreur n°1 : Transformer le baseline en « dépotoir de tout et n'importe quoi ».
Au début, vous ajoutez deux catégories dans la migration réservée aux tests, puis un article publié « pour faire pratique », puis deux pièces jointes « pour que ça ait l'air réel », et au bout d'un mois, vous avez la moitié de la base, et personne ne comprend ce qui dépend de quoi. Si un test a besoin d'un scénario riche, il est préférable de le rendre local via @Sql ou un jeu de données scénaristique séparé, et de garder le baseline ennuyeux et petit.
Erreur n°2 : Lier les tests aux id numériques parce que « c'est plus rapide ».
Les identifiants numériques dans la base sont une technique pratique, mais en tant que dépendance de test, ils sont souvent fragiles : l'ordre des insertions change, la séquence change, un autre enregistrement apparaît — et voilà que « Category id=1 » n'est soudainement plus Java. Pour les tables de référence, il est presque toujours préférable de s'appuyer sur une clé naturelle (code) et d'avoir une méthode de référentiel findByCode.
Erreur n°3 : Modifier le baseline commun pour un seul test qui échoue.
C'est un piège classique : un test échoue, vous pensez « je vais ajouter un enregistrement de plus dans le baseline, et tout ira bien », mais en réalité vous avez changé le point de départ pour des dizaines de tests, et le débogage devient une aventure. La bonne approche : si le cas est spécifique, isolez-le avec un @Sql local ou une préparation des données dans ce test précis.
Erreur n°4 : Noms de migrations sans signification.
Les fichiers comme V900__data.sql semblent inoffensifs quand il y en a un ou deux. Puis apparaît V901__data2.sql, et vous commencez à soupçonner que ce n'est pas Spring qui vous teste, mais un ancien sphinx égyptien. Le nom d'une migration doit répondre à la question « quoi et pourquoi », sinon la maintenance de la suite devient plus coûteuse que l'écriture des tests.
Erreur n°5 : Dépendance implicite au baseline sans contrat de test.
Lorsque le baseline existe mais n'est vérifié nulle part explicitement, vous apprenez sa rupture par une dizaine d'échecs « aléatoires » dans différents tests. Un petit test qui vérifie les enregistrements de référence clés rend cette dépendance explicite et améliore considérablement le diagnostic.
GO TO FULL VERSION