All articles

// Knowledge.log — 技術記事

@Transactional in integration tests: the rollback that hides the commit

Automatic rollback in a Spring Boot 4.1 test let a duplicate SKU pass that the commit rejects with 409. Where rollback isolates and when to clean up explicitly.

Almost every internal Spring testing guide has the same snippet in it. Someone pasted it years ago and nobody has looked at it since. It puts @SpringBootTest on top, @Transactional right below it, and adds a comment promising that the database comes back clean after every test. The suite stays green and fast, and nobody ever writes cleanup code.

This is the rule that gets passed around: "annotate the integration test with @Transactional; the automatic rollback cleans the database and isolates each test, so there is nothing to clean up". That rule is what we're testing here. No blogger made it up. It comes from the official documentation, and in plenty of setups it works well. The problem is that the rollback undoes the one moment production relies on most, which is the commit. And once the server runs on a random port, the rollback stops isolating the things it claims to isolate.

By the end you'll have two false greens reproduced with real output, an alternative test that reaches the commit on purpose, and a way to decide which classes in your suite can keep @Transactional.

What ran, and on which versions

The experiment is a small Maven project, run on September 28, 2026 with Spring Boot 4.1.1 (the current stable release), Spring Framework 7.0.9, Hibernate ORM 7.4.5.Final, in-memory H2 2.4.240, JUnit Jupiter 6.0.3, Temurin JDK 25.0.4 LTS and Maven 3.9.12. The Spring Boot system requirements page says 4.1.1 needs at least Java 17 and supports versions up to Java 26, so JDK 25 is well within the matrix.

mvn test finished with Tests run: 10, Failures: 0, Errors: 0, Skipped: 0 and BUILD SUCCESS. Everything passed, including the tests that exist only to show how little a pass can mean.

The tests use RestTestClient, from Spring Framework 7, as their HTTP client, pointed at the random port. In Boot 4.1, TestRestTemplate is no longer part of spring-boot-starter-test. It moved to the optional spring-boot-resttestclient module (org.springframework.boot.resttestclient.TestRestTemplate, together with @AutoConfigureTestRestTemplate). If your internal guide still imports the old package, it won't compile, so you'll never get as far as a rollback.

Where the thesis comes from

The thesis has a source, and the source is official. The TestContext Framework transaction documentation says:

> "Annotating a test method with @Transactional causes the test to be run within a transaction that is, by default, automatically rolled back after completion of the test."

And in the example that goes with it:

> "there is no need to clean up the database after the createUser() method runs, since any changes made to the database are automatically rolled back by the TransactionalTestExecutionListener."

The @Rollback annotation documentation confirms the default: "Rollback for integration tests in the Spring TestContext Framework defaults to true even if @Rollback is not explicitly declared." The Boot 4.1 testing docs say the same in the section on test slices: "By default, data JPA tests are transactional and roll back at the end of each test."

Beyond the official docs, the idea spread the usual way. A 2014 post by Marco Behler sums up the setup: "every test method in your suite is surrounded by an overarching Spring transaction. This transaction will be rolled back at the end of the test method regardless of it's outcome." That's an accurate description of the mechanism, and people turned that description into a rule they copy and paste. A 2025 post by Philip Riecks draws the line that internal guides tend to lose: "in slice tests, rollback is automatic with @Transactional; in full integration tests, we must take responsibility for our test data cleanup."

The warning on the same pages

The part almost nobody copies is on those same pages. The Spring Boot 4.1 testing documentation says:

> "If your test is @Transactional, it rolls back the transaction at the end of each test method by default. However, as using this arrangement with either RANDOM_PORT or DEFINED_PORT implicitly provides a real servlet environment, the HTTP client and server run in separate threads and, thus, in separate transactions. Any transaction initiated on the server does not roll back in this case."

The Framework's transaction page explains why. The test support "binds transaction state to the current thread (via a java.lang.ThreadLocal variable)". Anything that runs on another thread is outside the test-managed transaction, and in the documentation's own words, "such actions will be committed to the persistent store".

So the same documentation that recommends the rollback also tells you where it stops working. The internal guide only copied the first half.

The production path

The application has a Product entity with a SEQUENCE generator and a unique constraint on sku:

@Entity
@Table(name = "products", uniqueConstraints = @UniqueConstraint(name = "uk_products_sku", columnNames = "sku"))
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "product_seq")
    @SequenceGenerator(name = "product_seq", sequenceName = "product_seq", allocationSize = 50)
    private Long id;

    @Column(name = "sku", nullable = false, length = 40)
    private String sku;

    @Column(name = "name", nullable = false, length = 120)
    private String name;

    // ... constructors and getters omitted
}

POST /products calls a transactional service. That transaction runs on the HTTP thread and commits when the method returns:

@Service
public class ProductService {

    private final ProductRepository repository;

    public ProductService(ProductRepository repository) {
        this.repository = repository;
    }

    @Transactional
    public Product create(String sku, String name) {
        return repository.save(new Product(sku, name));
    }
}

When the database rejects the insert, an advice turns the exception into a 409 that includes the driver's message:

@RestControllerAdvice
public class CatalogErrorAdvice {

    @ExceptionHandler(DataIntegrityViolationException.class)
    public ResponseEntity<Map<String, String>> duplicate(DataIntegrityViolationException ex) {
        return ResponseEntity.status(HttpStatus.CONFLICT)
                .body(Map.of("error", "constraint_violation", "detail", rootMessage(ex)));
    }

    // rootMessage walks getCause() down to the root and returns "Class: message"
}

To see what reaches the database and when, application.properties turns on SQL and bind logging:

spring.datasource.generate-unique-name=true
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
logging.level.org.hibernate.orm.jdbc.bind=trace

False green #1: the INSERT that never reached H2

The test class follows the internal-guide pattern: RANDOM_PORT, @Transactional on the class, and methods deliberately ordered to build up the scenario.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Transactional
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class RollbackHidesDuplicateTest {

    static final String SKU = "SKU-DUP";

    @LocalServerPort
    int port;

    @Autowired
    ProductRepository repository;

    private RestTestClient client;

    @BeforeEach
    void setUp() {
        client = RestTestClient.bindToServer().baseUrl("http://localhost:" + port).build();
    }

    @Test
    @Order(1)
    void committedRowExists() {
        client.post().uri("/products")
                .contentType(MediaType.APPLICATION_JSON)
                .body(Map.of("sku", SKU, "name", "Camiseta DevDojo"))
                .exchange()
                .expectStatus().isCreated();
    }

    @Test
    @Order(2)
    void duplicateInsideTheTestTransactionEndsGreen() {
        Product pending = repository.save(new Product(SKU, "Camiseta duplicada"));
        assertThat(pending.getId()).isNotNull();
    }

    @Test
    @Order(3)
    void sameDuplicateThroughTheCommittedPathIsRejected() {
        client.post().uri("/products")
                .contentType(MediaType.APPLICATION_JSON)
                .body(Map.of("sku", SKU, "name", "Camiseta duplicada"))
                .exchange()
                .expectStatus().isEqualTo(409);
    }

    @Test
    @Order(4)
    void flushExposesTheSameViolationImmediately() {
        repository.save(new Product(SKU, "Camiseta duplicada"));
        repository.flush(); // the real test catches the exception and logs the root cause
    }
}

Order 2 is the test a team would actually write. It saves a product with a SKU that already exists and checks the id. Here's the log for that step:

[EVIDENCE] order1 POST committed row -> 201 CREATED {"id":1,"sku":"SKU-DUP","name":"Camiseta DevDojo"}
Hibernate: select next value for product_seq
[EVIDENCE] order2 persisted id=2 sku=SKU-DUP with NO exception raised; the rollback discards the pending INSERT

The log shows select next value for product_seq on the test thread, and no insert into products before the method returns. The test asserts that the id isn't null. That's true, and it's the only thing the test proves.

The Hibernate 7.4 user guide explains the mechanism. In the default AUTO mode, a flush happens "prior to committing a Transaction" and before queries that overlap with the queued actions. With SEQUENCE, the id comes from the sequence, so the INSERT can wait for the flush at commit: "This is valid for the SEQUENCE and TABLE identifier generators." But the test never commits. It rolls back. Nothing triggered a flush, so the INSERT sat in the queue and was thrown away with the transaction. H2 never saw the duplicate row, so it had no reason to check the constraint.

The same data through the committing path

Order 3 sends the exact same SKU through POST, which goes through the transactional service and reaches a commit:

[EVIDENCE] order3 POST committed path -> 409 CONFLICT {"detail":"JdbcSQLIntegrityConstraintViolationException: Unique index or primary key violation: \"PUBLIC.UK_PRODUCTS_SKU ... VALUES ( /* 1 */ 'SKU-DUP' )\"; SQL statement:\ninsert into products (name,sku,id) values (?,?,?) [23505-240]","error":"constraint_violation"}

It's the same operation with the same data. The only difference is that this time there was a commit, so Hibernate flushed and H2 answered with 23505.

The violation is there; the flush is missing

Order 4 repeats the save() inside the test transaction and calls repository.flush() straight after it:

[EVIDENCE] order4 flush raised DataIntegrityViolationException | root=JdbcSQLIntegrityConstraintViolationException: Unique index or primary key violation: "PUBLIC.UK_PRODUCTS_SKU ... 'SKU-DUP'

The green from order 2 never meant "there is no violation". It meant "there was no flush".

With IDENTITY, the false green goes away

The delayed INSERT comes from the id generator, not from @Transactional. The Coupon entity has the same structure but uses GenerationType.IDENTITY and the constraint uk_coupons_code. The Hibernate guide warns that "The IDENTITY generator must execute the insert right after calling persist()", because the id doesn't exist until the row has been inserted. In the log, both INSERTs run on the main thread inside the test transaction, and the second one is rejected on the spot:

Hibernate: insert into coupons (code,id) values (?,default)
[EVIDENCE] order5 first IDENTITY coupon insert executed inside the test transaction
Hibernate: insert into coupons (code,id) values (?,default)
[EVIDENCE] order5 IDENTITY duplicate raised DataIntegrityViolationException | root=JdbcSQLIntegrityConstraintViolationException: Unique index or primary key violation: "PUBLIC.UK_COUPONS_CODE ... 'CPN-DUP'

This goes a long way toward explaining why the rule survives in so many projects. With IDENTITY entities, a rollback test catches the constraint by accident. Then someone migrates to SEQUENCE with an allocationSize to get batched inserts, and the same suite keeps passing without testing any of this. Nobody touched the test, and it quietly stopped checking the constraint.

False green #2: RANDOM_PORT, two threads, two transactions

The second class sets the constraint aside and looks at isolation:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Transactional
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class RandomPortIsolationTest {

    // port, repository and client as in the previous class

    @Test
    @Order(1)
    void pendingInsertIsInvisibleToTheServerThread() {
        repository.save(new Product("SKU-PENDING", "Criado na transacao do teste"));
        client.get().uri("/products/{sku}", "SKU-PENDING")
                .exchange()
                .expectStatus().isNotFound();
    }

    @Test
    @Order(2)
    void serverSideWriteIsCommittedByItsOwnThread() {
        client.post().uri("/products")
                .contentType(MediaType.APPLICATION_JSON)
                .body(Map.of("sku", "SKU-SERVER", "name", "Escrito pelo servidor"))
                .exchange()
                .expectStatus().isCreated();
        client.get().uri("/products/{sku}", "SKU-SERVER")
                .exchange()
                .expectStatus().isOk();
    }

    @Test
    @Order(3)
    void rowCommittedByTheServerSurvivesTheTestRollback() {
        client.get().uri("/products/{sku}", "SKU-SERVER")
                .exchange()
                .expectStatus().isOk();
    }
}

The log:

[EVIDENCE] order1 GET SKU-PENDING while pending in the test transaction -> 404 NOT_FOUND
[EVIDENCE] order2 POST 201 | GET SKU-SERVER -> 200 OK (committed by the server thread)
[EVIDENCE] order3 after the rollback of order2 GET SKU-SERVER -> 200 OK (the test rollback did not clean it)

In order 1, the data the test "created" doesn't exist as far as the HTTP request is concerned. In this run, two causes stack up. The first is the one from false green #1: the log shows no insert on the test thread before the server's select on thread o-auto-1-exec-5. The second wasn't exercised here, but it is documented. Even with a flush, the row would still be uncommitted, and H2 defaults to read committed (the startup log shows Isolation level: READ_COMMITTED). At that level, the H2 documentation says "Dirty reads aren't possible". Either way, with this setup you can't prepare data in the test and expect the server to read it.

Orders 2 and 3 show the other side. The server wrote SKU-SERVER on its own thread, o-auto-1-exec-8, and committed it. The test in order 2 finished with a rollback, and order 3 still gets a 200. The test's rollback didn't undo anything the server wrote.

The first class had already shown the same thing, just less obviously. The 409 in order 3 only happened because the SKU-DUP row written by the server in order 1 survived order 1's rollback. The demo relies on that leak on purpose. In a real suite, that same dependency shows up as a test that passes on its own and fails when it runs after another one.

It gets worse. The log has a single Spring Boot banner for all three classes, and the product ids keep counting up from one class to the next: 1, 3 and 4 in the first class, 5 and 6 in the cleanup class, 8 in the random-port class. The context was cached and reused, so every class shared the same in-memory H2. Whatever one class leaves committed, the next one finds.

The executed alternative: no test transaction, explicit cleanup

The alternative test drops @Transactional, cleans the table before each method, and goes through the commit on purpose:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class ExplicitCleanupTest {

    @LocalServerPort
    int port;

    @Autowired
    JdbcTemplate jdbcTemplate;

    private RestTestClient client;

    @BeforeEach
    void cleanDatabase() {
        jdbcTemplate.update("delete from products");
        client = RestTestClient.bindToServer().baseUrl("http://localhost:" + port).build();
    }

    @Test
    @Order(1)
    void committedPathRejectsTheDuplicate() {
        client.post().uri("/products")
                .contentType(MediaType.APPLICATION_JSON)
                .body(Map.of("sku", "SKU-EXPLICIT", "name", "Camiseta"))
                .exchange()
                .expectStatus().isCreated();
        client.post().uri("/products")
                .contentType(MediaType.APPLICATION_JSON)
                .body(Map.of("sku", "SKU-EXPLICIT", "name", "Camiseta"))
                .exchange()
                .expectStatus().isEqualTo(409);
    }

    @Test
    @Order(2)
    void cleanupRunsBeforeTheNextTest() {
        Long rows = jdbcTemplate.queryForObject("select count(*) from products", Long.class);
        assertThat(rows).isZero();
    }
}

The log:

[EVIDENCE] cleanup rows after delete = 0
[EVIDENCE] order1 duplicate POST -> 409 CONFLICT {"detail":"JdbcSQLIntegrityConstraintViolationException: ... 'SKU-EXPLICIT' ... [23505-240]","error":"constraint_violation"}
[EVIDENCE] cleanup rows after delete = 0
[EVIDENCE] order2 rows visible at test start = 0

The duplicate now fails inside the test, through the same path production uses, and each method starts with an empty table without relying on a rollback. This has a real cost: someone has to write the cleanup and keep it up to date. What you get back is a test that checks what it says it checks.

The tools and what each one costs

APIWhat it solvesCost or trap
repository.deleteAll() in @BeforeEach/@AfterEachCleanup through the repository, no SQLWith several related entities, you have to manage the table order
JdbcTemplate (delete from ...)Direct cleanup, as in the experimentHandwritten SQL; ordering tables around FKs is on you
@Sql with @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)Script runs in its own transaction, "immediately committed" per the javadoc; executionPhase takes BEFORE_TEST_METHOD (default) or AFTER_TEST_METHODOne more file to keep in sync with the schema
@Commit / @Rollback(false)The transactional test commits at the end, so the flush happensThe data stays in the database; explicit cleanup is mandatory again
TestTransaction.flagForCommit() + TestTransaction.end()Commit partway through the method, with assertions afterwardsSame cost as @Commit, just applied at one point
saveAndFlush / saveAllAndFlushThe INSERT goes out immediately and the constraint is checked inside the testNo commit: fixes false green #1, not #2
@DirtiesContextCloses the context and evicts it from the cacheDoesn't clean rows. It's for a dirty context, not for data

The trap that catches half-finished fixes: if the class is still @Transactional, the deleteAll() in @BeforeEach runs inside the test transaction and gets rolled back along with everything else. It doesn't delete anything the server committed. You have two ways out. Either remove @Transactional from the class (in a slice that's transactional by default, use @Transactional(propagation = Propagation.NOT_SUPPORTED)), or do the cleanup with @Sql in ISOLATED mode.

For observability in the test, keep spring.jpa.show-sql=true on in any class that tests constraints. If the log shows no insert into between the save() and the end of the method, the constraint wasn't exercised, however green the test is. In production, a 409 that carries the constraint name in the response, the way CatalogErrorAdvice does, is the signal worth logging and counting. That's where a duplicate that slipped past the tests will show up.

A note on scope: all of this was observed on a single embedded H2 and is behavioural only. Nothing here measures performance. On another database, especially one that supports deferred constraint checking, repeat the experiment before generalising about exactly when the violation gets reported.

When the thesis still holds

The copied rule works well in a clearly bounded area: repository tests and @DataJpaTest slices, all on a single thread, with assertions made inside the same transaction. There, no server is writing on another thread. The rollback really does undo everything the test did, and "there is no need to clean up the database" is literally true. Rebuilding setup and writing cleanup by hand in that setting just costs time and slows the suite down.

Even there, one rule is worth following: if the test exists to check a constraint, force the flush with saveAndFlush or TestEntityManager.flush(). Without it, and with SEQUENCE, you're counting on some overlapping query to trigger an auto-flush by luck.

The thesis stops holding once RANDOM_PORT or DEFINED_PORT is involved, once any code runs on another thread, or once the test has to verify behaviour that only appears at commit.

Recommendation

DevDojo would keep @Transactional with rollback on @DataJpaTest slices and single-threaded repository tests, with an explicit flush whenever a constraint is involved. In @SpringBootTest with a real server, we'd take @Transactional off the class, clean up explicitly with JdbcTemplate or @Sql(ISOLATED), and write at least one test per critical constraint that goes through the committing path. Most of the suite stays in slices, and only a few tests run end to end.

The signs that a suite is trusting the rollback in places it doesn't reach are concrete: a test that passes alone and fails when it runs after another; a 404 when fetching over HTTP the data the test just saved; a duplicate that shows up as a 409 in production while the constraint test stays green; or a switch from IDENTITY to SEQUENCE after which the suite somehow got "more stable". Any one of these means it's time to review the class and make cleanup an explicit decision.

Next step

Search your suite for classes that combine @SpringBootTest(webEnvironment = RANDOM_PORT) with @Transactional. Pick one that tests a constraint, turn on spring.jpa.show-sql=true, and run it. If no insert into appears before the method ends, replace save with saveAndFlush or move the test onto the committing path, and see whether it still passes.

javaspring-boottesting

// Continue.training — 次のステップ

Knowledge only counts when it becomes practice.

Go back to the article, run the examples, and share what you learned.

Explore more articles