skip to content

Under RANDOM_PORT, a @Transactional test issues an HTTP POST that creates a row, yet the row isn't rolled back after the test. Why, and how do you handle it?

level: principalimportance: should knowfreq 38%

answer

  1. Rollback binds to the TEST thread
  2. Server handles request on its OWN thread → own committed tx
  3. No shared tx → nothing rolls back
  4. Clean up: @Sql / @AfterEach / fresh DB / @DirtiesContext
  5. Beware stale test-thread reads after the HTTP call

basics

~20 s

The embedded server handles the request on a different thread with its own transaction, so the test's transaction can't wrap or roll it back. Clean up explicitly — @Sql, delete in teardown, @DirtiesContext, or reset the datastore.

solid answer

~50 s

Spring's transactional test support rolls back by binding a transaction to the *test thread*. Under RANDOM_PORT the request goes over real HTTP and is processed by the embedded container on a *separate worker thread*, which opens and commits its own transaction. The test-thread transaction (from `@Transactional` on the test) never encloses the server's work, so nothing gets rolled back and the row persists. This is by design — you're testing a real server, not sharing its thread. Handle it by cleaning up deterministically: `@Sql` scripts before/after, deleting created data in `@AfterEach`, using `@DirtiesContext` to rebuild the context (heavy), resetting an in-memory DB, or using Testcontainers with a fresh DB per class. A subtle related trap: an entity read on the test thread after the HTTP call may hit a stale first-level cache or an unflushed state — verify via a fresh repository read or a new transaction. In short: don't rely on transactional rollback for real-HTTP tests.

code

java · 19 lines
java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OrderApiTest {
    @Autowired TestRestTemplate rest;
    @Autowired OrderRepository orders;

    @Test
    // @Transactional here would NOT roll back the server-side insert.
    void createsOrder() {
        var resp = rest.postForEntity("/api/orders", new OrderDto("book"), Void.class);
        assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        // verify with a FRESH read, not a stale test-thread entity
        assertThat(orders.count()).isEqualTo(1);
    }

    @AfterEach
    void cleanup() {
        orders.deleteAll(); // explicit cleanup — rollback won't do it for us
    }
}

go deeper

for a junior

May not know the thread boundary; learns rollback isn't guaranteed here.

for a middle

Explains the separate-thread transaction and cleans up with @AfterEach/@Sql.

for a senior

Also handles stale-read pitfalls and chooses isolation strategy per test.

for a principal

Sets suite-wide policy: few real-HTTP tests, explicit data ownership, fresh DB via Testcontainers, avoids @DirtiesContext as default.

## Why transactional rollback normally works Spring's `@Transactional` on a test (via `TransactionalTestExecutionListener`) starts a transaction **bound to the current (test) thread** and, by default, **rolls it back** at the end so tests don't pollute the DB. This works because the code under test runs on the **same thread**, sharing that thread-bound transaction/connection. ## Why it breaks under RANDOM_PORT / DEFINED_PORT With a real embedded server: 1. The test thread makes a **real HTTP request** (e.g. via `TestRestTemplate`). 2. The embedded container (Tomcat) dispatches that request to one of **its own worker threads**. 3. That worker thread runs your controller → service → repository and manages **its own transaction**, which **commits** normally when the request completes. 4. The test-thread transaction has **no relationship** to the server thread's transaction, so the test's rollback **cannot** undo the committed row. Result: data created through the HTTP call **survives** the test, potentially leaking into other tests. ## How to handle cleanup (options, roughly cheapest→heaviest) - **Delete explicitly in teardown:** `@AfterEach` that removes what you created (via a repository or JDBC). Precise and fast. - **`@Sql` scripts:** `@Sql(scripts="cleanup.sql", executionPhase = AFTER_TEST_METHOD)` or a reset script before each test. Declarative. - **Fresh datastore per test class:** Testcontainers with a new container/schema, or an in-memory DB reset between classes. Strong isolation. - **`@DirtiesContext`:** forces context rebuild — **heavy** and slow; use sparingly, and note it rebuilds beans, not necessarily the external DB. - **Design the suite** so real-HTTP tests are few and own their data lifecycle, keeping the fast transactional MockMvc/service tests as the bulk. ## Related subtleties - **Stale reads on the test thread:** after the HTTP call, verifying via a JPA entity that was loaded earlier in a test-thread transaction can show **stale** state (separate persistence context, first-level cache). Re-read through the repository in a fresh transaction to assert server-side effects. - **Async within the app** (e.g. `@Async`, event listeners on other threads) compounds the same thread/transaction boundary issue. - **DEFINED_PORT** shares this behavior and adds port-clash risk; prefer RANDOM_PORT. ## The principal-level takeaway Transactional rollback is a *same-thread* convenience. Real end-to-end HTTP tests trade that away for realism. Architect isolation explicitly (data ownership, dedicated DB, deterministic cleanup) rather than assuming rollback, and keep the count of such tests small because they're slower and statefully riskier.

  • Would adding @Transactional to the test fix the leftover data under RANDOM_PORT?
    No. The server processes the request on its own thread with its own committed transaction; the test-thread transaction can't wrap or roll it back. You must clean up explicitly (teardown delete, @Sql, fresh DB, etc.).
  • After the HTTP POST, asserting on a previously loaded JPA entity shows old values. Why?
    That entity lives in the test thread's persistence context (first-level cache) and wasn't refreshed; the server wrote in a different context/thread. Re-read through the repository in a fresh transaction to see the committed state.
  • When is @DirtiesContext justified for this?
    Rarely — it's slow because it rebuilds the Spring context. Prefer targeted data cleanup or a fresh DB per class; reserve @DirtiesContext for when context state (not just DB rows) is genuinely dirtied.

saying these in an interview costs you the question

  • Believing @Transactional on the test rolls back real-HTTP server writes
  • Asserting on a stale test-thread entity instead of a fresh read
  • Reaching for @DirtiesContext as the default cleanup (slow)
  • Assuming MockMvc and RANDOM_PORT have the same transaction semantics

context