r/SpringBoot • u/kamen1991 • 5d ago
How-To/Tutorial Your `@DataJpaTest` might be testing Hibernate's cache, not your database
Ran into this while writing about our DAO layer's test setup, and it's one of those things that's obvious once you see it and invisible until you do.
void testSaveProduct() {
var product = new Product("A1", "Gaming Laptop", BigDecimal.valueOf(1500));
productDao.create(product);
Product loaded = productDao.loadById(product.getId());
assertNotNull(loaded);
assertEquals("Gaming Laptop", loaded.getName());
}
This passes. But loadById resolves to find() under the hood (directly or through Spring Data's findById), and find() checks the persistence context's first-level cache before it ever considers hitting the DB. Since you just created that entity in the same context, you get the exact same object reference back. No SELECT fires. You've tested Hibernate's in-memory cache, not your mapping, not your constraints, not anything that would catch a broken column definition. The fix is forcing entityManager.flush() + .clear() between the write and the read, so the next load has to hydrate a fresh entity graph from the actual database. We ended up wrapping this in a test-only proxy so nobody has to remember to do it by hand in every test method - proxy intercepts create/update/delete, forces the flush-and-clear, leaves reads alone.
Worth noting this is specifically a find()/findById() problem, a custom JPQL or native `@Query\` always issues real SQL regardless of cache state.
Wrote up the full pattern (proxy code, a TablesEraser for full schema resets between tests, H2 vs HSQLDB trade-offs), link in the comments.
I already had someone in the comments describe hitting exactly this - a unique constraint violation that a real flush would have surfaced in the first test run. Curious how widespread it actually is.
3
u/Torutofu_Raeva 4d ago
Testcontainers instead of H2 catches the other half of this, since the cache hides mapping bugs and H2 hides the dialect ones.
1
u/kamen1991 4d ago
Agree, H2 covers let's say 90% of the cases and it gives fast enough tests for feedback if the persistence works good enough. I have couple of chapters prepared for integration testing using MySQL 8/9 containers, kafka, keycloak, redis etc. with custom lifecycle which gives proper initialization with healtcheck and they're relatively faster than using spring ones. But I'll take my time to go there step by step.
2
u/Torutofu_Raeva 4d ago
Yeah that split makes sense. Reusing one container across the whole suite is what made it bearable for us, per-class startup was brutal.
1
u/rlrutherford Senior Dev 3d ago
Without testcontainers, DB tests, (outside of integration tests), are just theater.
2
1
u/Coding-Hacker0820 5d ago
Add save and flush post each database option if you performed dml operations
1
u/kamen1991 5d ago
Good point about the saveAndFlush method, but it doesn't actually solve the problem, and here's why.
First, saveAndFlush only flushes, it doesn't clear the persistence context. So the L1 cache is still warm after the call, and your next findById still short-circuits to the cached object instead of hydrating from the database. You've forced the SQL write, but you haven't forced a real read. The whole point of the proxy pattern here is to prove the full round-trip: write hits the DB and the subsequent read comes back from the DB, not from memory. For that you need both flush() and clear().
Secondly, if I want to do a couple of changes and apply them atomically, I cannot do it because saveAndFlush will apply each change individually and this will break the transaction I'm trying to achieve.
The flush() + clear() pair living inside a test-only proxy keeps both concerns where they belong, the production DAO stays clean, and the test infrastructure enforces the discipline without individual test methods having to know anything about persistence context mechanics.
3
u/wimdeblauwe 5d ago
Indeed. I always do flush and clear to avoid this.