|

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

  1. 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.
  2. 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.
  3. 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:

  1. MySQLContainer starts up before tests,
  2. Each test can rely on an actual MySQL environment,
  3. @Transactional still automatically rolls back your changes, ensuring no leftover data.
  4. 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

  1. 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.
  2. @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.

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:

  1. spring-boot-starter-data-jpa + MySQL container: real DB environment.
  2. @Transactional at the test method level: each test runs in an isolated transaction.
  3. Mock or WireMock for external calls: no real side effects.
  4. 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:

  1. Using a real DB with Testcontainers, you approximate production’s concurrency and isolation—without leaving data behind.
  2. 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.

The book cover of 'Future-Proof Your Java Career With Spring AI', a guide for enterprise Java developers on becoming AI Orchestrators.

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.

View on Amazon Kindle →

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.