skip to content

Why can @Transactional rollback-per-test integration tests give false confidence, and how do you mitigate it?

level: principalimportance: should knowfreq 30%

answer

  1. One never-committed tx hides bugs
  2. First-level cache: save/find skips SQL
  3. No commit -> no flush -> constraint errors hidden
  4. Lazy loading always works (session open)
  5. Mitigate: flush + clear, TestTransaction, Testcontainers

basics

~20 s

Because the whole test runs in one never-committed transaction, save-then-find can be served from the JPA first-level cache without real SQL, flush/constraint errors may never surface, and lazy loading always works. Mitigate by flushing and clearing the persistence context, and testing commit paths.

solid answer

~50 s

A transactional test runs the whole method in a single transaction that is rolled back and never committed, which hides several real bugs. First, JPA's first-level cache (the persistence context) can return a saved entity from memory on a subsequent find, so a broken mapping or missing column never triggers SQL. Second, because there's no commit, deferred constraint checks and the flush that would throw a DataIntegrityViolation may never run — the test passes but production fails on commit. Third, lazy associations always resolve because the session stays open, masking LazyInitializationExceptions. Mitigate by calling entityManager.flush() to force SQL and surface constraint errors, entityManager.clear() (or TestEntityManager) to defeat the first-level cache before re-reading, and by having some tests run non-transactionally or use TestTransaction to exercise real commit boundaries — ideally against a real database via Testcontainers, not H2.

code

java · 27 lines
java
@DataJpaTest
class MappingCorrectnessTest {

    @Autowired TestEntityManager em; // flush()/clear() helpers

    @Test
    void notNullConstraintIsActuallyEnforced() {
        User u = new User();
        u.setEmail(null); // violates NOT NULL
        em.persist(u);

        // Without flush the rollback would hide this. flush() forces the INSERT SQL
        // so the DataIntegrityViolation surfaces INSIDE the test.
        assertThatThrownBy(em::flush)
            .isInstanceOf(PersistenceException.class);
    }

    @Test
    void readComesFromDbNotFirstLevelCache() {
        Long id = em.persistAndGetId(new User("[email protected]"), Long.class);
        em.flush();
        em.clear(); // evict the persistence context so find() hits the DB

        User loaded = em.find(User.class, id); // real SELECT -> validates mapping
        assertThat(loaded.getEmail()).isEqualTo("[email protected]");
    }
}

go deeper

for a junior

Not expected; may only know rollback keeps tests clean.

for a middle

Knows flush is needed to surface constraint errors in transactional tests.

for a senior

Explains first-level cache masking, no-commit-no-flush, lazy loading, and applies flush/clear + saveAndFlush.

for a principal

Designs the overall test strategy: rollback tests as fast default plus a deliberate layer of real-commit/real-DB (Testcontainers) tests for mappings, constraints, lazy boundaries, and AFTER_COMMIT side effects.

## The core problem With `@Transactional`, the entire test method executes in **one transaction that is rolled back and never committed**. Several things that would happen in production are skipped or short-circuited: ### 1. First-level cache masks broken persistence JPA's `EntityManager` keeps a **persistence context** (first-level cache). Within one transaction, `repo.save(entity)` then `repo.findById(id)` may return the **same in-memory instance** without issuing a SELECT. So a wrong column mapping, a bad `@Column(name=...)`, or a value the DB would have transformed goes undetected because no round-trip occurred. ### 2. No commit -> no flush -> hidden constraint violations Hibernate often **defers** INSERT/UPDATE SQL until flush, and flush is forced at commit. Since the test rolls back, the commit-time flush may never run, so **`DataIntegrityViolationException`**, NOT-NULL, unique, or FK violations that only appear at flush/commit **never surface**. The test is green; production throws on the real commit. ### 3. Lazy loading always "works" Because the transaction (and Hibernate session) stays open for the whole test, **lazy** associations resolve fine. In production, if the session closes before access, you get a **`LazyInitializationException`**. The test never catches this class of bug. ### 4. AFTER_COMMIT logic never runs `@TransactionalEventListener(phase = AFTER_COMMIT)` handlers, commit-time hooks, and outbox flushes don't fire because there's no commit — so integrations tied to commit are untested. ## Mitigations - **Force a flush**: call `entityManager.flush()` (or `repository.saveAndFlush(...)`, or `TestEntityManager.flush()`) so SQL is issued and constraint violations surface within the test. - **Clear the persistence context**: `entityManager.clear()` (or `TestEntityManager` which encourages `flush()` + `clear()`) after a save, so a subsequent read hits the database instead of the cache — validating the mapping. - **Test commit paths explicitly**: use `TestTransaction` to commit and re-read across boundaries, or drop `@Transactional` for specific tests and clean up manually. - **Use a realistic database**: run these tests against the **real engine via Testcontainers**, not H2 — dialect and constraint behavior differ, and H2 can itself mask or fabricate failures. - **Cover lazy-loading boundaries**: add a test where the association is accessed after the transaction/session would realistically be closed, or assert fetch strategy explicitly. ## The judgment call Rollback-per-test is a great **default** — fast and isolated for the majority of repository/service tests. The principal-level insight is knowing its **blind spots** and deliberately layering in a smaller set of non-rollback / real-commit / real-DB tests to cover mapping correctness, constraint enforcement, lazy boundaries, and commit-time side effects. Treat transactional rollback tests as necessary but **not sufficient**.

  • Your test saves an entity that violates a NOT NULL constraint yet the test passes. Why, and how do you fix it?
    Hibernate deferred the INSERT and the rollback skipped the commit-time flush, so the violation never executed. Call entityManager.flush() / saveAndFlush() to force the SQL so the DataIntegrityViolationException surfaces in the test.
  • Why can a repository test with H2 pass while production on Postgres fails?
    H2 has different dialect, type coercion, and constraint semantics, and the rollback plus first-level cache can mask round-trips. Testcontainers with the real engine gives faithful behavior.
  • How would you catch a LazyInitializationException that transactional tests hide?
    Access the lazy association outside the transaction/session boundary (e.g. a non-transactional test, or after em.clear()/detach), or assert fetch strategy, since inside the open session lazy loading always succeeds.

saying these in an interview costs you the question

  • Believing a green rollback test proves constraints and mappings are correct
  • Not knowing the first-level cache can serve save-then-find without SQL
  • Assuming lazy loading tested inside a transactional test reflects production
  • Thinking flush() and commit are the same thing

context