Mastering Spring & MSA Transactions – Part 14: Advanced Transaction Testing: Real DB, Testcontainers, and Mocking External Calls
In-memory database tests with @Transactional
are great for quick feedback, but they don’t fully capture real concurrency or vendor-specific behaviors in MySQL, PostgreSQL, or Oracle. Plus, if your app calls external services—like a payment gateway—you can’t rely on local rollback to undo a “live” network call. That’s where Testcontainers and mocks come in. By spinning up ephemeral database containers and simulating external APIs, you can run high-fidelity transaction tests without polluting your real environment.
This post walks you through why you’d want a real DB test environment, how to combine it with @Transactional
, and what to do about external calls that can’t be rolled back. Along the way, we’ll show a working example so you can see how the pieces fit together in a more advanced local scenario.
1) Why “Real DB” Tests Matter
- In-Memory vs. Real Database
- In-Memory (like H2): Fast, convenient, but it might skip vendor-specific isolation or concurrency quirks you’ll face in production.
- Real Database: Testing on MySQL/PostgreSQL ensures your code meets the real engine’s locking, indexing, and isolation rules. This is essential if you suspect concurrency or transaction anomalies that H2 won’t reveal.
- Ephemeral Environment
- Using an actual local DB can be clunky: leftover tables, manual resets, and environment drift.
- Testcontainers addresses this by spinning up a Docker-based DB for each test class or suite—so each run starts fresh and leaves nothing behind.
- Confidence in “Real-World” Behavior
- If you rely solely on H2, you might only catch “basic” transaction errors. Real DB container tests help ensure your code handles the same isolation levels, error states, or performance considerations you’ll see in production.
2) Combining @Transactional
with Testcontainers
2.1) Minimal Setup
Gradle:
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// ...
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:mysql'
runtimeOnly 'mysql:mysql-connector-java'
}
Test class skeleton:
@SpringBootTest
@Testcontainers
@Transactional
class PaymentServiceTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
// spring.datasource.url, username, password can be overridden
// by hooking into container.getJdbcUrl(), etc.
@Autowired
PaymentService paymentService;
@Test
void testPaymentTransaction() {
// This method runs in a transaction that Spring automatically
// rolls back when finished. The DB is a real MySQL container.
}
}
With this approach:
- MySQLContainer starts up before tests,
- Each test can rely on an actual MySQL environment,
@Transactional
still automatically rolls back your changes, ensuring no leftover data.- Upon completion, the container is shut down, leaving no state behind.
2.2) Real Commits vs. Ephemeral
- If you want to verify actual commits across tests, you can either remove
@Transactional
from certain methods or set@Rollback(false)
. - But typically, we do want ephemeral rollback so that each test runs in isolation—no leftover state.
2.3) Handling Concurrent Tests
- By default, you might run tests sequentially. If you run them in parallel, each test still sees a unique or shared container. For advanced concurrency tests, you may spin up separate containers or carefully share the same container with different transactions.
- We’ll tackle concurrency more deeply in another part, but the key is that you can replicate near-production DB settings in ephemeral form.
3) Mocking External Services
Even with a real DB, local rollbacks can’t revert calls to:
- Payment Gateways,
- REST APIs for shipping or inventory in other systems,
- Message brokers that send messages to external queues.
If your test is calling “real” external endpoints, you can cause unintended side effects—like charging real credit cards or polluting a real queue. Instead, mock them:
3.1) WireMock or @MockBean
- WireMock
- Launch a mock HTTP server that intercepts calls to (e.g.)
http://payment-gateway.local
and simulates responses. - Great for complex responses or error scenarios.
- Launch a mock HTTP server that intercepts calls to (e.g.)
@MockBean
- If your external call is wrapped in a Spring
@Service
or interface, you can replace that bean with a mock in the test context. - That ensures you can simulate success/failure responses, control the behavior, and avoid real side effects.
- If your external call is wrapped in a Spring
3.2) Ensuring No Side Effects
- If your test calls
paymentGateway.charge(...)
, the local DB might roll back after the test, but the external system wouldn’t. - With mocks, you never hit a real external endpoint. So your “@Transactional” rollback remains valid for everything else.
3.3) Example: PaymentService with External “BankClient”
@SpringBootTest
@Testcontainers
class PaymentServiceTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("paydb")
.withUsername("test")
.withPassword("test");
@Autowired
PaymentService paymentService;
@MockBean
BankClient bankClient;
@Test
@Transactional
void testPaymentProcess() {
// given
when(bankClient.charge(any(), anyInt())).thenReturn(true);
// when
paymentService.processPayment("order123", 100);
// then
// DB changes are auto-rolled back,
// external call is faked by bankClient mock
// so no real bank was charged
}
}
- Because BankClient is mocked, the test remains fully local. The container-based MySQL handles real DB logic, but external calls are stubs.
4) A Thorough Example
To illustrate the synergy of Testcontainers + @Transactional
+ mocking external calls:
spring-boot-starter-data-jpa
+ MySQL container: real DB environment.@Transactional
at the test method level: each test runs in an isolated transaction.- Mock or WireMock for external calls: no real side effects.
- Auto-rollback on test completion, plus ephemeral container teardown → no leftover data, no external pollution.
@SpringBootTest
@Testcontainers
class OrderServiceIntegrationTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@Autowired
OrderService orderService;
@MockBean
ExternalShippingClient shippingClient;
@Test
@Transactional
void testOrderPlacementAndShipping() {
// 1) Force shipping client success
when(shippingClient.scheduleShipment(anyString())).thenReturn("SHIP-ID-999");
// 2) Act
orderService.placeOrder("product42", 2);
// 3) Assert
// Check real DB for new order row, shipping data
// Rolls back after test finishes
}
}
Results: You get real DB interactions, verifying your actual queries, concurrency locks, etc. Meanwhile, any external call is mocked. After the test, local changes vanish, the container shuts down, and you’re left with a clean slate.
Conclusion
@Transactional
remains a powerful ally even when you step beyond an in-memory DB or face external integrations. By:
- Using a real DB with Testcontainers, you approximate production’s concurrency and isolation—without leaving data behind.
- Mocking or stubbing external calls, you avoid accidental side effects that can’t be undone by local rollbacks.
This advanced approach ensures your transaction logic is thoroughly validated under realistic conditions, while still enjoying the cleanliness of ephemeral containers and automatically reversed DB states. In the bigger picture, this is a stepping stone to truly distributed transaction testing, but it already unlocks robust verification of your local service’s DB logic, bridging the gap between simple H2 tests and production-grade reliability.

Enjoyed this article? Take the next step.
Future-Proof Your Java Career With Spring AI
The age of AI is here, but your Java & Spring experience isn’t obsolete—it’s your greatest asset.
This is the definitive guide for enterprise developers to stop being just coders and become the AI Orchestrators of the future.