DEV Community

Kamen
Kamen

Posted on Originally published at kamenivanov.substack.com

What Spring Data Tests Can Miss: Testing Beyond Hibernate’s Persistence Context (Chapter 7)

Here's the mechanism, before the war story: every EntityManager keeps a first-level cache, an identity map of every entity it's touched in the current persistence context. Call find() (which is what loadById resolves to, directly or through Spring Data's derived findById) for an entity that's already managed, and Hibernate doesn't ask the database anything. It just hands you back the object it already has. That's not a bug. It's the entire point of the first-level cache, and most of the time it's exactly the behavior you want. The problem is that a green @DataJpaTest can’t tell the difference between 'I verified this round-trips through the database' and 'I got the same Java object reference back.' And I’ve watched teams ship code confident in tests that were only ever validating L1 cache state - right up until a missing constraint violation or a column mapping bug blew up in production because the data was never actually flushed and re-hydrated.

In this chapter of the Evolutionary Architecture series, we're tearing down the illusion of the default Spring Data test. We'll look at exactly where the first-level cache hides bugs, and build a testing architecture around explicit EntityManager clearing, generic test fixtures, and in-memory isolation - so a green test means what you think it means.


What Standard Spring Tests Actually Test

When you write a standard Spring Data test using @DataJpaTest and a repository interface, your test looks clean and harmless:

@Test
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());
}
Enter fullscreen mode Exit fullscreen mode

It passes, but let's see here what actually happened:

You call create(). Hibernate generates a managed entity inside the persistence context - the row may or may not have hit the database yet, depending on flush mode. Then, even inside the @Transactional wrapper the test runs in, calling loadById() immediately after doesn't issue a SELECT. The entity you just created is still sitting in the L1 cache, still attached to the same persistence context, and find() short-circuits straight to it. You get the exact object reference back. The table itself never answered a query. To be precise about where this bites and where it doesn't: this specifically applies to find() - based lookups. A custom JPQL or native @Query method always issues real SQL, cache or no cache Hibernate will still try to reconcile the result with anything already managed, but the round-trip to the database happens either way. The gap is narrower than "all your reads are fake." It's specifically the find()/findById() path, which is also the one most CRUD test suites lean on hardest, because it's the one that requires the least code to write. So this test hasn't verified persistence. It's verified Hibernate's in-memory bookkeeping and Spring Data's proxy machinery which isn't nothing, but it's not what the test's name claims. A strict column constraint, a broken mapping, a query that only breaks against a real dialect - all of that can hide behind a green checkmark here.


Forcing a Real Write-and-Read Boundary

If a test exists to verify persistence rather than domain behavior, it needs to force Hibernate to flush its write-behind queue to the database, then clear the persistence context before reading anything back. The next read has to hydrate a fresh entity graph from scratch - no shortcuts, no identity map hits.

Doing that by hand in every test method is exactly the kind of boilerplate that gets skipped under deadline pressure, which defeats the point. So we push the discipline down into the test infrastructure itself, using a test-only proxy that wraps every DAO and forces the flush-and-clear automatically after any write.

Architectural Boundary Note: This proxy layer lives exclusively in test scope (TestConfig, under src/test/java). It's physically unreachable from production code and never ships in a production artifact. Production wiring injects the unproxied DAO implementations directly, so Hibernate keeps its normal write-behind batching with zero caching penalty outside of tests.

Here is our test-only CrudDaoProxy:

public abstract class CrudDaoProxy<
    IdType,
    Domain extends AbstractCreatable<IdType>,
    Dao extends CrudDao<IdType, Domain>
> implements CrudDao<IdType, Domain> {

    protected final Dao proxied;
    private final EntityManager entityManager;

    protected CrudDaoProxy(Dao proxied, EntityManager entityManager) {
        this.proxied = proxied;
        this.entityManager = entityManager;
    }

    @Override
    public Domain create(Domain domain) {
        final var saved = proxied.create(domain);
        flushAndClear();
        return saved;
    }

    @Override
    public Domain update(Domain domain) {
        final var updated = proxied.update(domain);
        flushAndClear();
        return updated;
    }

    @Override
    public Domain loadById(IdType id) {
        return proxied.loadById(id);
    }

    @Override
    public void delete(Domain domain) {
        proxied.delete(domain);
        flushAndClear();
    }

    protected void flushAndClear() {
        // Forces SQL out of write-behind cache into DB
        entityManager.flush(); 
        // Wipes L1 cache, forcing a real JDBC round-trip on next read
        entityManager.clear(); 
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice what's deliberately not overridden here: loadById just delegates straight through to proxied.loadById(id), no flush-and-clear wrapper. It doesn't need one, by the time a test calls it, the previous write already forced the clear. Any DAO with search capability gets the same treatment one level up:

public abstract class SearchableCrudDaoProxy<
        IdType,
        Domain extends AbstractCreatable<IdType>,
        Params extends AbstractParams<? extends SortBy>,
        Dao extends CrudDao<IdType, Domain> & SearchableDao<Domain, Params>
    > extends CrudDaoProxy<IdType, Domain, Dao> implements SearchableDao<Domain, Params> {

    protected SearchableCrudDaoProxy(Dao proxied, EntityManager entityManager) {
        super(proxied, entityManager);
    }

    @Override
    public ResultPage<Domain> search(Params criteria) {
        return proxied.search(criteria);
    }
}
Enter fullscreen mode Exit fullscreen mode

search() passes straight through too - it's backed by a Specification, which always compiles to real SQL regardless of cache state, so there's nothing to force. The proxy only intervenes exactly where the shortcut exists: create, update, delete. That asymmetry isn't an oversight, it's the whole design - wrap the three operations that can lie, leave the two that can't.

A concrete feature DAO just extends the generic proxy and adds whatever custom query methods it has:

public class ProductsDaoProxy extends SearchableCrudDaoProxy<
    UUID,
    Product,
    ProductSearchParams,
    ProductDao
> implements ProductDao {

    public ProductsDaoProxy(ProductDao proxied, EntityManager entityManager) {
        super(proxied, entityManager);
    }

    @Override
    public Product loadBySku(String sku) {
        return proxied.loadBySku(sku);
    }
}
Enter fullscreen mode Exit fullscreen mode

loadBySku isn't part of the generic CRUD/search contract, so it gets a one-line override with no wrapping - same reasoning as loadById.


Ground-Zero: The TablesEraser

Relying purely on Spring's @Transactional rollback-per-test can still hide second-level cache pollution, batching side effects, or a connection leak that only shows up across tests, not within one.

So every component test starts from an empty schema. TablesEraser deletes every row from every table before each test runs:

public class TablesEraser {

    public static void emptyAllTables(
        EntityManagerFactory emf,
        DbQueryFactory queryFactory
    ) {
        emptyAllTables(emf, queryFactory, s -> true);
    }

    public static void emptyAllTables(
        EntityManagerFactory emf,
        DbQueryFactory queryFactory,
        Predicate<String> filter
    ) {
        final EntityManager em = emf.createEntityManager();
        try {
            em.getTransaction().begin();

            // Disable foreign key checks dynamically
            em
              .createNativeQuery(queryFactory.disableForeignKeys())
              .executeUpdate();

            // Fetch all table names and truncate them
            final List<String> allTableNames = em
              .createNativeQuery(queryFactory.selectAllTableNames())
              .getResultList();
            for (final var tableName : allTableNames) {
                if (filter.test(tableName)) {
                    em
                    .createNativeQuery("DELETE FROM " + tableName)
                    .executeUpdate();
                }
            }

            // Re-enable foreign keys and commit
            em
              .createNativeQuery(queryFactory.enableForeignKeys())
              .executeUpdate();
            em.getTransaction().commit();
        } catch (Throwable th) {
            em.getTransaction().rollback();
            throw new RuntimeException(th);
        } finally {
            em.close();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Worth being precise here: this is a DELETE FROM per table, not a TRUNCATE. That's a deliberate trade-off, not an oversight - DELETE respects the same transaction we're already managing manually and rolls back cleanly if anything in the loop throws, where a bare TRUNCATE in most dialects auto-commits and can't be undone mid-loop. It costs a little raw speed against a TRUNCATE, and it's worth it for a test-suite reset that can fail safely.


Reusable Tests: AbstractCrudTestCase & AbstractSearchableTestCase

Shared assertions cut real repetition, as long as the abstraction stays shallow. The moment a failing test sends you on a five-class inheritance hunt to understand what actually broke, the abstraction has stopped paying rent.

public abstract class AbstractCrudTestCase<
        ID,
        D extends AbstractCreatable<ID>,
        DAO extends CrudDao<ID, D>
    > extends AbstractDaoTest {

    @Test  
    void testCreate() {  
        final D expectedEntity = runSave();  
        final D dbEntity = getDao().loadById(expectedEntity.getId());  
        assertNotNull(dbEntity, "missing entity");  
        assertEquals(expectedEntity.getId(), dbEntity.getId());  
        getAsserter().assertDeepEquals(expectedEntity, dbEntity);  
    }

    @Test
    void testCreateExisting() {
        final D existing = runSave();
        final var ex = Assertions.assertThrows(
            IllegalArgumentException.class,
            () -> getDao().create(existing)
        );
        Assertions.assertEquals("Entity existing!", ex.getMessage());
    }

    ...

    protected abstract DAO getDao();
    public abstract D createDomain();
    protected abstract DeepEqualsAsserter<D> getAsserter();
}
Enter fullscreen mode Exit fullscreen mode

A concrete case like ProductDaoTestCase stays thin, it implements the domain-specific payload generator (createDomain()) and inherits the full CRUD and search suite for free. It's not only that, though. It also adds its own tests for loadBySku, because that's a query method the generic contract has no way to know about. That's the right shape for this kind of abstraction - the base class owns everything that's structurally identical across every DAO, and the concrete case owns exactly the part that makes this DAO different. If a shared base class tried to guess at loadBySku too, you'd be back to fighting a framework instead of using one.

Not every test in this hierarchy exists to confirm the happy path. One of those tests is in the AbstractSearchableTestCase that specifically is checking a boundary condition:

@Test
public void testSearchNullParams() {
    for (int i = 0; i < 5; i++) {  
        runSave(createDomainWithUniqueData());  
    }

    final ResultPage<Domain> result = getDao().search(null);  
    assertEquals(1, result.totalPages());  
    assertEquals(5, result.totalHits());  
    assertEquals(5, result.elements().size());
}
Enter fullscreen mode Exit fullscreen mode

That test earned its place in the suite the hard way. It didn't start out passing.


What the Null-Params Test Actually Caught

The first version of search() in SearchableDaoImpl assumed a Params object would always be there:

private Pageable toPage(Params criteria) {
    return PageRequest.of(
            criteria.getPage(),
            criteria.getSize(),
            getSortOrDefault(criteria.getSortBy(), criteria.getSortDirection())
    );
}
Enter fullscreen mode Exit fullscreen mode

Reasonable assumption, right up until testSearchNullParams called getDao().search(null) and got a NullPointerException on criteria.getPage() instead of a result page. Which is exactly the point of writing that test before the feature felt finished - a caller passing null to mean "no filters, give me the default page" is a completely ordinary thing to do, and the DAO was punishing it with a stack trace instead of handling it.

The fix has two parts, and both matter for the same reason: Params is a generic type parameter here, so there's no single field-by-field way to "default" a null one into existing. Every concrete DAO defines its own Params subtype with its own fields, which means the null case has to be handled once, centrally, before any of that generic machinery gets involved - not pushed down into every feature DAO's createSpecification implementation as a null-check they'd all have to remember to write.

Paging falls back to a fixed default when there is no parameter to base it on:

private Pageable toPage(Params criteria) {
    if (criteria == null) {
        return PageRequest.of(DEFAULT_PAGE, DEFAULT_SIZE, CREATED_AT_DESC_SORT);
    }
    return PageRequest.of(
            criteria.getPage(),
            criteria.getSize(),
            getSortOrDefault(criteria.getSortBy(), criteria.getSortDirection())
    );
}
Enter fullscreen mode Exit fullscreen mode

And filtering falls back to "match everything" rather than calling the abstract createSpecification(params) with nothing for it to read:

@Override
public ResultPage<Domain> search(Params params) {
    final Pageable pageable = toPage(params);
    final Specification<Entity> spec = params == null
            ? Specification.unrestricted()
            : createSpecification(params);
    final Page<Entity> result = repository.findAll(spec, pageable);
    final List<Domain> content = result.getContent()
            .stream()
            .map(transformer::createOutput)
            .toList();

    return new ResultPage<>(result.getTotalPages(), result.getTotalElements(), content);
}
Enter fullscreen mode Exit fullscreen mode

Specification.unrestricted() is doing real work in that ternary, not just avoiding a null check. The first instinct is usually to pass Specification.where(null) - which is the older, more commonly documented way of expressing "no restriction" in Spring Data JPA. But recent Spring Data versions added a second where(...) overload for PredicateSpecification, and a bare null literal is ambiguous between the two. The compiler can't infer which where you meant, so it refuses to guess. unrestricted() exists to sidestep exactly that ambiguity, it says "match everything" without needing a null argument for the compiler to argue about.

These issues stay hidden if we only test scenarios where valid parameters are provided. Handling unexpected edge cases is where a test suite truly adds value, the testSearchNullParams test might seem boring because it just calls the search method with null param, but it proved its worth because it prevented letting a bug to impact other parts of the system.


Choosing an In-Memory Database

Component tests need to be fast enough that nobody's tempted to skip them, which rules out spinning up a containerized database for every run of a basic CRUD suite. That leaves a choice between H2 and HSQLDB, and it's worth being honest about what that choice costs.

We run H2 in strict compatibility mode (MODE=MySQL, matching our production dialect) for this layer, mainly because startup is sub-second and there's no container to wait on which matters more than it sounds like it should, multiplied across a few hundred local test runs a day. HSQLDB is arguably more stable in isolation, but its dialect compliance with modern MySQL and PostgreSQL features - JSON columns, window functions, strict identifier quoting, lags enough that a test can pass against HSQLDB and break the moment it hits real MySQL. Either way, neither in-memory engine replicates the production query planner, locking behavior, or dialect-specific edge cases. That was never the job we're asking them to do here.

So we split the responsibility deliberately: H2 handles fast, local validation of the mapping and constraint logic through the DAO proxies in this chapter. Later in the series, when we get to end-to-end integration testing, a dedicated suite runs against a real MySQL 9 Testcontainer to validate the behavior H2 can't, that's the layer that's actually authoritative for production-identical engine behavior, and it's allowed to be slower because it runs less often.


What's Next?

With the DAO layer's persistence boundaries actually proven against real database round-trips - not just Hibernate's bookkeeping, the natural next question is what happens to boundaries like this one under deadline pressure.

In Chapter 8: Why almost every ‘Temporary’ Workaround is Permanent, we step back from the happy-path development. The biggest threat to a clean multi-module boundary usually isn’t a missing design pattern, it’s the workaround that was supposed to be temporary. We’ll look at how an unisolated external dependency, a quick infrastructure leak, or a skipped domain boundary compounds quietly over time, before moving on to building the business service layer.

Codebase & Architecture Blueprint

The entire evolutionary architecture of this project is tracked using strict Git tags. To clone the repository and switch exactly to the state established in Chapter 7, use the following link:

Note: All core modules are configured with strict compilation-level boundaries. Compile and run mvn clean install to see the structure in action. Maven version 3.9.* and Java 25 are required.

◀️ Read Chapter 6: The DAO Implementation Layer
▶️ Read Chapter 8: Why almost every ‘Temporary’ Workaround is Permanent (Coming soon)

Top comments (0)