🎯 OBJECTIF
Comprendre comment :
@DataJpaTest charge uniquement la couche JPA sans le reste du contexte Springflush() + clear() sont obligatoires avant les assertions🧠 MODÈLE MENTAL
@DataJpaTest est un slice test — Spring ne démarre que la couche JPA : Hibernate, EntityManager, repositories, transaction manager. Pas de controllers, pas de services. C'est rapide et ciblé sur ce qu'on veut vraiment tester : est-ce qu'Hibernate fait ce qu'on lui demande ?
Chaque test tourne dans une transaction rollbackée automatiquement. Mais le piège est subtil : Hibernate peut retourner un objet depuis son cache L1 après un save(), même si le SQL n'a pas encore été envoyé. flush() force l'envoi du SQL. clear() vide le cache L1. Sans ces deux appels, un test peut être vert alors qu'un INSERT a silencieusement échoué — le findById() suivant retourne l'objet du cache, pas depuis la DB.
@DataJpaTest
class StockRepositoryTest {
@Autowired
private StockRepository stockRepository;
@Autowired
private EntityManager entityManager;
}javaSpring démarre automatiquement : Hibernate, EntityManager, repositories, TransactionManager. Chaque test s'exécute dans une transaction rollbackée automatiquement — pas besoin de cleanup.
@Test
void should_insert_stock() {
Stock stock = new Stock();
stock.setProduct("TV");
stockRepository.save(stock);
entityManager.flush(); // ✓ force l'INSERT SQL en DB
entityManager.clear(); // ✓ vide le cache L1
// maintenant findById() lit DEPUIS LA DB, pas depuis le cache
Stock dbStock = stockRepository.findById(stock.getId()).orElseThrow();
assertEquals("TV", dbStock.getProduct());
}java🚨 Sans flush() + clear(), le test peut être faux
stockRepository.save(stock);
Stock dbStock = stockRepository.findById(stock.getId()).orElseThrow();
// ⚠️ Hibernate retourne l'objet du cache L1
// même si l'INSERT a échoué — test VERT, bug en PRODjavaDirty checking (UPDATE automatique) :
@Test
void should_update_with_dirty_checking() {
Stock stock = new Stock();
stock.setProduct("TV");
entityManager.persist(stock);
entityManager.flush();
entityManager.clear();
Stock dbStock = entityManager.find(Stock.class, stock.getId());
dbStock.setProduct("PHONE"); // ✓ pas d'appel à save()
entityManager.flush(); // ✓ Hibernate détecte la diff → UPDATE
entityManager.clear();
assertEquals("PHONE", entityManager.find(Stock.class, stock.getId()).getProduct());
}javaCascade :
@Test
void should_cascade_insert() {
Stock stock = new Stock();
Blocking blocking = new Blocking();
blocking.setReason("DAMAGED");
blocking.setStock(stock);
stock.setBlockings(List.of(blocking));
entityManager.persist(stock); // ✓ CascadeType.ALL → INSERT blocking aussi
entityManager.flush();
entityManager.clear();
assertEquals(1, entityManager.find(Stock.class, stock.getId()).getBlockings().size());
}javaorphanRemoval :
@Test
void should_delete_with_orphanRemoval() {
Stock stock = new Stock();
Blocking blocking = new Blocking();
blocking.setStock(stock);
stock.setBlockings(new ArrayList<>(List.of(blocking)));
entityManager.persist(stock);
entityManager.flush();
entityManager.clear();
Stock dbStock = entityManager.find(Stock.class, stock.getId());
dbStock.getBlockings().clear(); // ✓ orphanRemoval → DELETE automatique
entityManager.flush();
entityManager.clear();
assertEquals(0, entityManager.find(Stock.class, stock.getId()).getBlockings().size());
}javaLock optimiste (@Version) :
@Test
void should_throw_optimistic_lock_exception() {
Stock stock = new Stock();
entityManager.persist(stock);
entityManager.flush();
entityManager.clear();
Stock s1 = entityManager.find(Stock.class, stock.getId()); // version = 0
Stock s2 = entityManager.find(Stock.class, stock.getId()); // version = 0
s1.setProduct("TV");
entityManager.flush(); // version → 1, OK
s2.setProduct("PHONE"); // version attendue = 0, mais DB = 1
assertThrows(
OptimisticLockException.class,
() -> entityManager.flush() // ✓ 0 rows updated → exception
);
}javaContrainte DB :
@Test
void should_fail_on_null_constraint() {
Stock stock = new Stock(); // product = null, @Column(nullable=false)
entityManager.persist(stock);
assertThrows(
PersistenceException.class,
() -> entityManager.flush() // ✓ contrainte SQL violée
);
}java| H2 | Vraie DB locale | Testcontainers | |
|---|---|---|---|
| Vitesse | ✅ ultra rapide | ✅ rapide | ❌ plus lent |
| Fidélité prod | ❌ partielle | ✅ bonne | ✅ excellente |
| Isolation | ✅ totale | ❌ dépend de l'env | ✅ totale |
| JSONB / UUID / ILIKE | ❌ | ✅ | ✅ |
| Docker requis | ❌ | ❌ | ✅ |
Avec Testcontainers :
@Testcontainers
@DataJpaTest
class StockRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:15");
@DynamicPropertySource
static void configure(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
}javaRègle de choix
H2 pour valider le comportement JPA général (cascade, dirty checking, orphanRemoval). Testcontainers dès que la requête utilise du SQL natif Postgres (JSONB, ILIKE, CTE, FOR UPDATE SKIP LOCKED) ou que les contraintes exactes importent.
⚡ TL;DR — chaque concept en une ligne
@DataJpaTest
✓ Slice test JPA — charge uniquement Hibernate + EntityManager + repositories, rollback automatique après chaque test.
⚠ Ne charge pas les services ni les controllers — si tu en as besoin, utilise @SpringBootTest.
flush() + clear()
✓ flush() envoie le SQL en attente, clear() vide le cache L1 — les assertions lisent depuis la vraie DB.
⚠ Sans ces deux appels, findById() peut retourner l'objet du cache L1 même si l'INSERT a silencieusement échoué.
H2 vs Testcontainers ✓ H2 = rapide, sans infrastructure, suffisant pour valider le comportement JPA pur. ⚠ H2 ne reproduit pas PostgreSQL pour les types natifs, les plans d'exécution et les locks — les tests H2 peuvent passer alors qu'une requête SQL native explose en prod.
🎓 À retenir
@DataJpaTest : les mappings JPA (cascade, orphanRemoval, @Version), le dirty checking, les requêtes JPQL et les contraintes DB. Ce sont des comportements qu'un mock ne peut pas valider — il faut une vraie transaction et un vrai EntityManager.@DynamicPropertySource. Ni l'un ni l'autre → datasource de application.yml.entityManager.flush() avant d'appeler une méthode de repository — si tu mélanges entityManager.persist() et stockRepository.findBy...() dans le même test, flush d'abord pour que la requête JPQL voie les données persistées.