Using Spring Test For Database Transactions And Rollback
Database integration tests are most useful when they exercise the same persistence rules as production code. A test that saves an order, updates an account, or publishes an audit record should verify the real database behaviour rather than relying only on mocks. Spring Test makes this practical by connecting a test method to a managed transaction and cleaning up its changes afterwards.
The usual expectation is simple: arrange data, run the operation, assert the result, and roll back everything when the test finishes. That expectation is reliable when the test transaction, application transaction, and database connection share the same execution path. It becomes less reliable when code starts another thread, opens an independent connection, or commits work outside Spring’s test-managed boundary.
This distinction matters for Java and Groovy projects running on PostgreSQL, MySQL, or a containerised database. It also matters in Australian teams where a test suite may run locally in Melbourne, in a Sydney-based CI service, and against a managed database in another region. Repeatable cleanup reduces the risk of tests depending on execution order or leaving sensitive records behind.
A good arrangement therefore combines @Transactional, a correctly configured transaction manager, explicit rollback rules, and test data that reflects real application constraints. The aim is not to hide database behaviour, but to make each test isolated, understandable, and representative.
Why Transactional Tests Matter
A database transaction groups several statements into one unit of work. If a test inserts a customer, creates an invoice, and then checks a foreign-key relationship, rollback can remove the complete unit after the assertions run. The next test starts with the same baseline instead of inheriting rows from an earlier method.
Spring’s TestContext framework supports this through a test-managed transaction. A class or method annotated with @Transactional can cause the test method to execute inside a transaction, provided Spring can find a suitable PlatformTransactionManager. With Spring Boot, this is commonly supplied by JPA, JDBC, or a configured transaction manager bean.
Rollback is especially valuable for tests that use generated identifiers, unique constraints, and database triggers. A failed test should not leave behind an email address, account number, or external reference that causes a later test to fail. Isolation also makes failures easier to reproduce: the result depends on the test’s setup rather than yesterday’s local database state.
How Spring Test Manages Rollback
By default, a transaction started for a Spring test is rolled back when the test method completes. @Rollback(false) changes that behaviour for a particular test, while @Commit expresses the same intent more clearly when committed data is genuinely required. These annotations affect the test-managed transaction; they do not magically reverse commits performed by unrelated transactions.
A typical JUnit test can look like this:
@SpringBootTest
@Transactional
class OrderRepositoryIT {
@Autowired
OrderRepository orders;
@Test
void storesAnOrder() {
Order order = orders.save(new Order("AU-1001"));
assertThat(orders.findById(order.getId())).isPresent();
}
}
The repository call participates in the transaction associated with the test thread. At the end of the method, Spring rolls back the insert. For JPA, the persistence context may still hold managed entities, so assertions should distinguish between an object already present in memory and a value actually retrieved from the database.
The transaction lifecycle also affects setup methods. Spring’s transactional test listeners typically run @BeforeEach and the test method within the test transaction, while @BeforeAll and @AfterAll usually sit outside that method-level boundary. Database setup in a class-level lifecycle method may therefore commit or require its own transaction. Being explicit about where fixtures are created prevents confusing cleanup behaviour.
Choosing Annotations And Boundaries
@Transactional at class level is convenient for repository and service integration tests. A method-level annotation can be better when only selected tests need a database transaction. If a test must inspect committed state, use @Commit sparingly and keep that test isolated, since committed fixtures can affect every later test that uses the same schema.
The propagation setting is important. Propagation.NOT_SUPPORTED suspends a test-managed transaction, which is useful when verifying behaviour that requires autocommit or a real commit. Propagation.NEVER fails if a transaction is active. These options are valuable for testing transaction boundaries, but they remove the automatic rollback protection, so database cleanup must be handled separately.
TestTransaction provides programmatic control when a test needs several phases. A test can end the current transaction, start a new one, or flag the current transaction for commit or rollback. This is useful for an outbox workflow: first commit an order, then begin another transaction and verify that a polling component can see the committed row.
Do not use a committed test simply to make another operation visible. First check whether the code under test is using a separate transaction with REQUIRES_NEW, a different connection, or a separate process. The mismatch often indicates that the test needs a more precise boundary rather than a disabled rollback.
A Reliable Database Test Setup
A rollback-based test still needs a predictable database configuration. Use the same dialect and relevant extensions as production, apply migrations before the suite, and ensure the test profile points to a disposable schema or container. H2 can be convenient, but it may not reproduce PostgreSQL locking, JSON operators, sequences, or constraint behaviour accurately.
The following choices help match the test style to the behaviour being checked:
| Test style | Useful Spring setup | Rollback expectation | Main caution |
|---|---|---|---|
| Repository slice | @DataJpaTest |
Usually automatic | It loads a focused application slice |
| Service integration | @SpringBootTest and @Transactional |
Usually automatic | All required beans and the correct manager must load |
| JDBC test | @JdbcTest |
Usually automatic | Check SQL dialect and generated keys |
| Commit visibility test | @Transactional with @Commit or TestTransaction |
Explicitly controlled | Clean up committed rows |
| HTTP test with mock server | @SpringBootTest and MockMvc |
Depends on thread and transaction participation | The request may use the test transaction or a separate one |
| Real HTTP test | Full server and external client | Usually not covered by test rollback | Use database cleanup or isolated schemas |
For repeatable fixtures, SQL scripts, factory methods, and test data builders are preferable to a shared collection of mutable entities. @Sql can load setup data before a test and clear it afterwards, with the execution phase chosen deliberately. A migration tool such as Flyway or Liquibase should establish the schema; test-specific scripts should add only the records needed for a scenario.
Australian organisations also need to treat fixture data as real data from a privacy perspective. Under the Privacy Act 1988 and the Australian Privacy Principles, production customer records should not casually appear in a developer laptop or a CI database. Synthetic names, addresses, and identifiers are safer, particularly when a team works across Sydney, Brisbane, and overseas contractors. Rollback limits persistence, but it does not prevent data from appearing in logs, traces, or failed test reports.
Where Rollback Stops Working
A test-managed transaction is normally bound to the current thread. If the application starts an @Async method, submits work to an executor, or sends a message to another process, that work does not automatically join the test transaction. The background operation may commit successfully while the test transaction later rolls back, leaving rows behind.
The same issue occurs with REQUIRES_NEW. Spring suspends the outer transaction and starts an independent one. If that inner transaction commits, rolling back the test transaction cannot undo it. A stored procedure or direct JDBC connection can create similar surprises if it commits independently or uses a connection outside the configured transaction manager.
Preemptive timeouts deserve special care. Some test frameworks enforce them by running the test body on another thread. Spring may associate the transaction with the original test thread, while the actual assertions and database calls execute elsewhere. Prefer non-preemptive timeout assertions where possible, and verify thread behaviour when a transactional test produces inconsistent cleanup.
A common false assumption appears with @SpringBootTest(webEnvironment = RANDOM_PORT). The test client communicates with a running server through a real HTTP request, so the controller executes on a server thread, not inside the test method’s transaction. The request can commit data even though the test method itself is marked @Transactional. MockMvc in the same application context often behaves differently because request processing can remain in the test process.
Patterns For REST And Async Code
For a service or repository test, place the transaction around the method under test and assert database state before the method exits. For a REST test using MockMvc, determine whether the request shares the test thread and transaction. If it does not, use a disposable database, truncate known tables, or reset the schema between tests rather than relying on the outer annotation.
Testing an asynchronous workflow requires checking both persistence and delivery. A useful pattern is to insert an input record, commit it, trigger the worker, and wait for a specific observable result using a bounded polling utility. Do not assert immediately after submitting a task, and do not assume rollback will remove work that the worker committed on its own connection.
For messaging systems, database rollback also cannot retract a message already sent to Kafka, RabbitMQ, or an email provider. Use a fake publisher for unit-level tests and a local broker or test topic for integration tests. The outbox pattern can make the database part transactional, but the test still needs to verify the separate delivery step.
Teams maintaining Java middleware often find practical transaction examples in middleware testing notes, particularly when comparing container-managed transactions with application-level test boundaries. The key is to identify which component owns the commit instead of treating every operation in one test as a single atomic action.
Keeping Suites Fast And Trustworthy
Rollback is usually faster than rebuilding a schema for every method, but it does not solve every source of test interference. Sequence values may continue increasing after rollback, database caches may retain state, and static application objects can preserve information between tests. Assertions should normally check business properties rather than exact generated IDs unless identifier allocation is the behaviour under test.
Parallel execution needs an additional isolation strategy. Two tests can each roll back their own transactions and still conflict through locks, shared reference data, external services, or unique values. Separate schemas, database containers, or carefully partitioned test data make parallel suites more dependable. This is particularly relevant when a local suite runs on developer machines while CI uses several workers during an Australian business day.
Keep a small set of committed-transaction tests for behaviours that truly require visibility across connections. Keep most repository and service tests rollback-based, and reserve full HTTP plus asynchronous tests for a smaller integration layer. That division gives fast feedback without pretending that a thread-bound transaction covers an entire distributed workflow.
When a test fails, inspect the transaction manager, active thread, propagation setting, SQL logs, and connection pool configuration. Confirm whether the row was inserted inside the expected transaction and whether any later component committed independently. A clear transaction trace is often more useful than adding another cleanup statement.
A Spring test rollback is a boundary, not a universal undo button. It reliably cleans changes made inside the test-managed transaction, while separate threads, new transactions, real HTTP servers, brokers, and external systems require their own isolation and verification. The reader should remember to match the cleanup strategy to the actual commit path: use automatic rollback for shared transactional work, and explicit fixtures or environment isolation wherever work crosses that boundary.