Spring Boot Testcontainers and Stripe Integration: How @Transactional Test Propagation Shares a Transaction with the Service and Hides UUID Duplication, WireMock Stubs Accept Any Idempotency-Key Header on Retried Requests, and @TransactionalEventListener(AFTER_COMMIT) Never Fires in @Transactional Tests Leaving UUID-in-Listener Failures Undetected
The previous posts in this series traced Stripe idempotency failures to specific framework mechanisms: AOP proxy ordering in Spring Boot, reactive cold publisher re-subscription in Spring Data MongoDB, CDI interceptor priority in Quarkus. This post asks a different question: why do tests fail to catch these bugs before they reach production? Three Spring Boot Testcontainers testing patterns create systematic blind spots. Each blind spot is mechanically distinct from the others. Together, they explain why a developer can have a test suite that reliably passes against a real PostgreSQL database managed by Testcontainers, with real WireMock stubs standing in for Stripe, and still ship a service that double-charges customers.
These are not “write more tests” problems. They are problems with the specific mechanics of @Transactional propagation in test contexts, WireMock’s default stub-matching behavior, and the Spring application event system’s transaction-phase contract. Understanding the mechanics tells you exactly which assertion is missing and why the test silently passes without catching the production failure mode.
Background: Testcontainers, @SpringBootTest, and WireMock in Spring Boot integration tests
A standard Spring Boot integration test setup for billing code combines three elements. Testcontainers provides a real PostgreSQL container (or MongoDB, or whatever the production database is), started by JUnit via a @Container static field. @SpringBootTest(webEnvironment = RANDOM_PORT) starts a full Spring application context on a random port. WireMock provides an HTTP mock server that impersonates the Stripe API; the test registers stubs for specific Stripe endpoints and the production StripeClient bean is configured to point to the WireMock server URL instead of the real Stripe API.
This setup is the right approach for testing payment integration code. It uses a real database, so JPA schema creation, constraint enforcement, and auto-generated IDs behave exactly as in production. It uses WireMock rather than a mocked Java interface, so the test exercises the actual HTTP client, serialization, retry configuration, and response parsing. The test is high-fidelity by design.
The problem is not the setup. The problem is the assertions — or rather, the assertions that are written versus the assertions that are not written. Each of the three blind spots below is a case where the test passes because of something it does not check, not because the production code is correct.
Stripe’s idempotency contract is simple but precise: a POST to any Stripe mutating endpoint (PaymentIntent creation, charge creation, refund creation) with the same Idempotency-Key header value returns the same response and does not create a new resource, for 24 hours per API key. Two requests with different Idempotency-Key values create two distinct resources. If your service sends UUID_A on attempt 1 and UUID_B on attempt 2 (because a retry re-generated the UUID), Stripe sees them as two separate billing intents and charges the customer twice. The Idempotency-Key header is the only deduplication mechanism Stripe offers; there is no server-side detection of “same customer, same amount, same description” as a duplicate.
Mode 1: @Transactional test method propagation — the test shares the service’s transaction and generates UUID once, hiding the per-request UUID generation failure in concurrent production traffic
The developer writes a @SpringBootTest integration test with @Transactional on the test method. They do this for a good reason: the test inserts a customer into the PostgreSQL database, calls the billing service, and asserts a BillingAttempt record was created. Putting @Transactional on the test method ensures the database is clean after each test run — Spring rolls back the test-method transaction on completion, so each test starts with a consistent database state.
The billing service method is annotated with @Transactional(propagation = REQUIRED), which is the Spring default. REQUIRED means: “participate in the existing transaction if one exists; start a new one if there is none.” When the test method runs under @Transactional, a transaction is active. When the test calls the billing service, the service’s @Transactional(REQUIRED) sees the test’s outer transaction and joins it. The service does not open a new transaction. The service runs inside the test’s transaction.
The UUID is generated at the top of the service method body. The service calls WireMock (standing in for Stripe) with the UUID. WireMock returns 200. The service saves a BillingAttempt record. The test asserts one BillingAttempt row was inserted and one request was received by WireMock. The test passes and rolls back.
This is correct for testing that the service works in isolation. It does not test that the service is idempotent under concurrent load.
// BillingServiceIntegrationTest.java — unsafe Mode 1 test
// The @Transactional on the test method shares the service's transaction.
// UUID is generated once. Test passes. But concurrent production requests are not exercised.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class BillingServiceIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
.withDatabaseName("billing_test")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureDataSource(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private BillingService billingService;
@Autowired
private BillingAttemptRepository repository;
// Test uses @Transactional for clean rollback between tests.
// Developer's reasoning:
// "Service is @Transactional(REQUIRED) — it participates in my test transaction.
// I'm testing the service logic, not transaction isolation.
// UUID is generated inside the service — it's deterministic per-call; same for concurrency."
//
// The problem:
// In production, two concurrent HTTP requests to POST /billing trigger two separate
// @Transactional service invocations. Each runs in its own transaction, from line 1.
// Each generates UUID.randomUUID() → UUID_A and UUID_B.
// Each calls Stripe with a different key → ch_A and ch_B. Customer charged twice.
//
// In this test, there is only ONE transaction (the test's outer transaction).
// Service @Transactional(REQUIRED) joins the outer transaction.
// UUID is generated ONCE. ONE Stripe call. Test passes.
// The test has never exercised the concurrent production scenario.
@Test
@Transactional
void chargesCustomerOnce() throws Exception {
// Arrange: insert customer in the shared test transaction.
String customerId = "cust_001";
repository.insertCustomer(customerId, "stripe_cus_abc", 1000L, "2026-10");
// Arrange WireMock stub: accept any POST /v1/payment_intents.
stubFor(post(urlEqualTo("/v1/payment_intents"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"id\":\"pi_001\",\"status\":\"succeeded\"}")));
// Act: call service once.
billingService.chargeCustomer(customerId);
// Assert: one BillingAttempt row.
List<BillingAttempt> attempts = repository.findAll();
assertThat(attempts).hasSize(1);
// Assert: WireMock received one request.
verify(1, postRequestedFor(urlEqualTo("/v1/payment_intents")));
// Test passes and rolls back.
// The test NEVER:
// - calls billingService.chargeCustomer(customerId) a second time from a separate thread
// - asserts that both calls produce the same Idempotency-Key header
// - asserts that only one BillingAttempt row was created after two concurrent calls
}
}
In production, the POST /billing HTTP endpoint receives two concurrent requests for the same customer. Request 1 enters the billing service. Spring opens Tx1. The service generates UUID_A = UUID.randomUUID().toString() at method entry. Request 2 enters the billing service at the same time. Spring opens Tx2. The service generates UUID_B = UUID.randomUUID().toString() at method entry. Both calls proceed to Stripe with different keys. Both calls succeed. Two BillingAttempt rows are inserted. The customer sees two charges on their card statement.
This specific failure is distinct from the @Retryable + @Transactional ordering failure. There, a single request retries and generates UUID_B because proceed() re-runs the method body. Here, two separate requests each generate their own UUID without any retry involved. The test missed it because the test was never written to simulate two concurrent callers.
Fixing the test: explicit concurrent idempotency assertion
The correct test for idempotency under concurrent access does not use @Transactional on the test method (because that would create a shared transaction). It calls the service from two separate threads and asserts that exactly one BillingAttempt was created with exactly one distinct Idempotency-Key.
// BillingServiceIdempotencyTest.java — correct concurrent test (no @Transactional on test)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class BillingServiceIdempotencyTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
.withDatabaseName("billing_test")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configure(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired private BillingService billingService;
@Autowired private BillingAttemptRepository repository;
@Autowired private JdbcTemplate jdbc;
@BeforeEach
void cleanUp() {
jdbc.execute("DELETE FROM billing_attempts");
}
@Test
void concurrentCallsForSameCustomerProduceOneCharge() throws Exception {
// Arrange: insert customer outside the test transaction.
String customerId = "cust_concurrent_001";
jdbc.update("INSERT INTO customers (id, stripe_id, amount_cents, billing_period) " +
"VALUES (?, ?, ?, ?)", customerId, "stripe_cus_xyz", 1000L, "2026-10");
// Stub: record all POST /v1/payment_intents requests; return success for all.
stubFor(post(urlEqualTo("/v1/payment_intents"))
.willReturn(aResponse().withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"id\":\"pi_concurrent\",\"status\":\"succeeded\"}")));
// Act: two concurrent calls for the same customer.
ExecutorService pool = Executors.newFixedThreadPool(2);
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch go = new CountDownLatch(1);
Future<?> f1 = pool.submit(() -> {
ready.countDown();
go.await();
billingService.chargeCustomer(customerId);
return null;
});
Future<?> f2 = pool.submit(() -> {
ready.countDown();
go.await();
billingService.chargeCustomer(customerId);
return null;
});
ready.await();
go.countDown(); // release both threads simultaneously
f1.get(10, TimeUnit.SECONDS);
f2.get(10, TimeUnit.SECONDS);
pool.shutdown();
// Assert: exactly one BillingAttempt row.
List<BillingAttempt> attempts = repository.findAll();
assertThat(attempts).hasSize(1);
// Assert: WireMock received exactly one POST /v1/payment_intents.
verify(1, postRequestedFor(urlEqualTo("/v1/payment_intents")));
// Assert: the one Stripe request carried a stable, deterministic key.
// If the service uses content-hash keys: key must be the SHA-256 of the billing inputs.
List<LoggedRequest> stripeRequests = findAll(postRequestedFor(urlEqualTo("/v1/payment_intents")));
String key = stripeRequests.get(0).getHeader("Idempotency-Key");
assertThat(key).isNotNull().isNotBlank();
assertThat(key).isEqualTo(expectedContentHashKey(customerId, "2026-10", 1000L));
}
private String expectedContentHashKey(String customerId, String billingPeriod, long amountCents) {
// Mirror the production key generation logic.
return Hashing.sha256()
.hashString(customerId + "|" + billingPeriod + "|" + amountCents,
StandardCharsets.UTF_8)
.toString()
.substring(0, 36);
}
}
This test forces the production idempotency mechanism to engage: both threads open independent transactions, the service must either use a content-hash UUID (same key regardless of which thread computed it first) or a database-level unique constraint on (customer_id, billing_period) to prevent the second thread from inserting a duplicate record. If the service generates UUID.randomUUID() at method entry, this test fails — one of the two concurrent calls will insert a second BillingAttempt row and send a second WireMock request with a different Idempotency-Key.
The @BeforeEach cleanup uses JdbcTemplate.execute() directly rather than relying on transaction rollback. This is necessary because the test does not use @Transactional on the test method — there is no outer test transaction to roll back. The cleanup runs outside any test transaction in its own auto-committed JDBC statement.
Mode 2: WireMock URL-only stub matching — the stub accepts UUID_B on the retried request and the test never inspects the Idempotency-Key journal
This blind spot is about what WireMock validates by default versus what the developer assumes WireMock validates.
The developer uses @WireMockTest (from com.github.tomakehurst:wiremock-jre8 or the org.wiremock:wiremock library for newer versions) together with Testcontainers PostgreSQL. They register a stub for Stripe’s PaymentIntent creation endpoint. The stub is defined as follows:
// Standard WireMock stub for Stripe PaymentIntent — matches URL and method only.
stubFor(post(urlEqualTo("/v1/payment_intents"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"id\":\"pi_test\",\"status\":\"succeeded\"}")));
This stub matches any POST to /v1/payment_intents. It does not inspect any request headers. It does not check the Idempotency-Key header. It does not check the request body’s amount or currency fields. Any POST to that URL gets a 200 response.
The billing service uses @Retryable with the retry exception list including StripeNetworkException.class. When the test simulates a Stripe transient failure (by returning 500 on the first request and 200 on the second), the developer registers two stubs in sequence:
// Simulating Stripe transient 500 on first attempt, success on second.
stubFor(post(urlEqualTo("/v1/payment_intents"))
.inScenario("stripe-retry")
.whenScenarioStateIs(Scenario.STARTED)
.willReturn(aResponse().withStatus(500)
.withBody("{\"error\":{\"type\":\"api_error\",\"message\":\"Internal server error\"}}"))
.willSetStateTo("retrying"));
stubFor(post(urlEqualTo("/v1/payment_intents"))
.inScenario("stripe-retry")
.whenScenarioStateIs("retrying")
.willReturn(aResponse().withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"id\":\"pi_retry_success\",\"status\":\"succeeded\"}")));
The billing service is called. Attempt 1 sends the request with Idempotency-Key: UUID_A. WireMock returns 500. The service throws StripeNetworkException. @Retryable catches it. @Retryable calls MethodInvocation.proceed() for attempt 2. The method body re-executes from line 1. UUID.randomUUID() generates UUID_B. The service sends attempt 2 with Idempotency-Key: UUID_B. WireMock is now in state “retrying” and returns 200. The service completes successfully.
The test asserts: one BillingAttempt row in the database with status “succeeded”, and WireMock received two requests. The test passes.
The developer’s mental model: “My retry test works. The service retried and eventually succeeded. The test validates my retry logic.”
What the test did not assert: that both Stripe requests carried the same Idempotency-Key header. WireMock received POST /v1/payment_intents with Idempotency-Key: UUID_A on attempt 1 and Idempotency-Key: UUID_B on attempt 2. The stubs matched both because they only check URL and method. The WireMock request journal has both entries. The test never called findAll(postRequestedFor(...)) to inspect the journal. The test never used verify() with a withHeader() constraint on Idempotency-Key.
In production, Stripe is not WireMock. Stripe receives UUID_A from attempt 1. It records the 500 that it returned (or the gateway timeout that your client interpreted as a network error). The POST /v1/payment_intents with UUID_A may have been committed by Stripe before the 500 was returned — a 500 from Stripe on a PaymentIntent creation is ambiguous about whether the intent was created. When attempt 2 arrives with UUID_B, Stripe treats it as a distinct intent and creates ch_B alongside ch_A. The customer is charged twice.
This failure mode is the same as documented in the Spring Boot + @Retryable post and the Spring Data MongoDB post. The question here is why tests did not catch it: because the test validated WireMock received two requests but never validated that both requests carried the same Idempotency-Key.
The fix: WireMock request journal assertion on Idempotency-Key header stability
The correct assertion for a retry test is: all retried requests sent to Stripe must carry the same Idempotency-Key header value. WireMock’s findAll() method retrieves the full request journal and lets you inspect each logged request’s headers.
// BillingServiceRetryTest.java — correct WireMock retry test with Idempotency-Key assertion
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@WireMockTest(httpPort = 8089)
@Testcontainers
class BillingServiceRetryTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");
@DynamicPropertySource
static void configure(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
registry.add("stripe.base-url", () -> "http://localhost:8089");
}
@Autowired private BillingService billingService;
@Autowired private JdbcTemplate jdbc;
@BeforeEach
void cleanUp() {
jdbc.execute("DELETE FROM billing_attempts");
}
@Test
void retryKeepsIdempotencyKeyStable() {
// Arrange: insert customer.
jdbc.update("INSERT INTO customers (id, stripe_id, amount_cents, billing_period) " +
"VALUES (?, ?, ?, ?)", "cust_retry", "stripe_cus_retry", 2000L, "2026-10");
// Arrange: Stripe returns 500 on first attempt, 200 on second.
stubFor(post(urlEqualTo("/v1/payment_intents"))
.inScenario("retry-test")
.whenScenarioStateIs(Scenario.STARTED)
.willReturn(aResponse().withStatus(500)
.withBody("{\"error\":{\"type\":\"api_error\"}}"))
.willSetStateTo("second-attempt"));
stubFor(post(urlEqualTo("/v1/payment_intents"))
.inScenario("retry-test")
.whenScenarioStateIs("second-attempt")
.willReturn(aResponse().withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"id\":\"pi_ok\",\"status\":\"succeeded\"}")));
// Act.
billingService.chargeCustomer("cust_retry");
// Assert: WireMock received exactly 2 requests (one failed, one succeeded).
List<LoggedRequest> stripeRequests = findAll(postRequestedFor(urlEqualTo("/v1/payment_intents")));
assertThat(stripeRequests).hasSize(2);
// *** THE CRITICAL ASSERTION ***
// Both retry attempts must carry the SAME Idempotency-Key header.
// If UUID.randomUUID() is called at method entry, attempt 1 gets UUID_A
// and attempt 2 gets UUID_B — this assertion fails and exposes the bug.
String keyOnAttempt1 = stripeRequests.get(0).getHeader("Idempotency-Key");
String keyOnAttempt2 = stripeRequests.get(1).getHeader("Idempotency-Key");
assertThat(keyOnAttempt1)
.as("Idempotency-Key must be present on attempt 1")
.isNotNull().isNotBlank();
assertThat(keyOnAttempt2)
.as("Idempotency-Key must be present on attempt 2")
.isNotNull().isNotBlank();
assertThat(keyOnAttempt1)
.as("Idempotency-Key must be IDENTICAL across all retry attempts — " +
"if these differ, @Retryable is re-generating UUID at method entry")
.isEqualTo(keyOnAttempt2);
// Assert: exactly one BillingAttempt record (not two).
int attemptCount = jdbc.queryForObject(
"SELECT COUNT(*) FROM billing_attempts WHERE customer_id = ?",
Integer.class, "cust_retry");
assertThat(attemptCount).isEqualTo(1);
}
}
This assertion structure catches the @Retryable UUID re-generation bug directly. If the service generates UUID.randomUUID() at method entry (the unsafe pattern), the assertion assertThat(keyOnAttempt1).isEqualTo(keyOnAttempt2) fails immediately, naming the exact mismatch: “expected UUID_A but was UUID_B.” The error message includes the expected value and the actual value, making the bug trivially diagnosable from the test failure output.
There is also a WireMock stub variant that enforces idempotency key stability at the stub level rather than at the assertion level. Register the stub to match on both the URL and the specific header value expected on retry:
// Alternative: stub-level enforcement of Idempotency-Key stability
// This requires knowing the expected key in advance — feasible with content-hash keys.
// With content-hash keys: the key is deterministic from inputs, so you can compute it in the test.
String expectedKey = Hashing.sha256()
.hashString("cust_retry|2026-10|2000", StandardCharsets.UTF_8)
.toString().substring(0, 36);
// Both stubs require the expected key.
// If attempt 2 sends a different key, WireMock returns a 404 (no matching stub),
// the service throws an unexpected exception, and the test fails explicitly.
stubFor(post(urlEqualTo("/v1/payment_intents"))
.withHeader("Idempotency-Key", equalTo(expectedKey))
.inScenario("retry-strict")
.whenScenarioStateIs(Scenario.STARTED)
.willReturn(aResponse().withStatus(500)
.withBody("{\"error\":{\"type\":\"api_error\"}}"))
.willSetStateTo("second-attempt-strict"));
stubFor(post(urlEqualTo("/v1/payment_intents"))
.withHeader("Idempotency-Key", equalTo(expectedKey))
.inScenario("retry-strict")
.whenScenarioStateIs("second-attempt-strict")
.willReturn(aResponse().withStatus(200)
.withBody("{\"id\":\"pi_strict_ok\",\"status\":\"succeeded\"}")));
The stub-level enforcement approach is strict but brittle: it assumes you know the expected key in the test, which requires the key to be deterministic (content-hash) rather than random. For services that use random UUIDs in their current (broken) state, the journal-assertion approach is more diagnostic: the test receives two requests, extracts the keys, and asserts they are equal, giving a clear failure message instead of a stub-not-matched error.
Mode 3: @TransactionalEventListener(phase = AFTER_COMMIT) — the listener never fires in @Transactional tests, and UUID-in-listener UUID generation goes completely untested
The developer uses the Spring application event system to decouple the billing database write from the Stripe call. The service method writes a BillingRecord to the database and publishes a ChargeRequested application event, all within a single @Transactional method. A separate @Component handles the event with @TransactionalEventListener(phase = AFTER_COMMIT): it receives the event only after the publishing transaction has committed, avoiding the problem of calling Stripe before the billing record is durably committed. The listener then calls Stripe with an Idempotency-Key it generates at its own entry point.
The @TransactionalEventListener(phase = AFTER_COMMIT) contract is: the listener method is invoked only if the transaction in which ApplicationEventPublisher.publishEvent(event) was called commits successfully. If that transaction rolls back, the listener is not invoked. This is explicitly the correct behavior for external side effects: you do not want to charge a customer on Stripe if the billing database record was not durably written.
The collision with @Transactional tests: a @Transactional test always rolls back. If the test publishes a ChargeRequested event by calling the billing service inside a @Transactional test, the service’s transaction (which has joined the test’s outer transaction via REQUIRED propagation) never commits — it rolls back at the end of the test. The AFTER_COMMIT phase condition is never met. The @TransactionalEventListener never fires.
The test asserts that the event was published (by using Spring’s ApplicationEvents test support or by registering a test-scoped @EventListener bean that captures events). The event was published (it was placed in the transaction synchronization registry before the rollback). But the AFTER_COMMIT listener never ran. The test has zero coverage of the Stripe call and zero coverage of the UUID generation inside the listener.
// ChargeEventListener.java — production listener with UUID generated at listener entry
@Component
public class ChargeEventListener {
private final StripeClient stripeClient;
private final BillingAttemptRepository repository;
// Developer's design:
// "AFTER_COMMIT means the billing record is committed before I call Stripe.
// UUID is generated here, at the start of the listener — this is where the Stripe
// call begins, so UUID generation and Stripe call are atomic from the listener's
// perspective. The listener has its own @Transactional context for saving the attempt."
//
// The problem:
// The listener is invoked by Spring's event system for each AFTER_COMMIT event delivery.
// If the listener itself fails (e.g., Stripe timeout) and an async retry mechanism
// (e.g., @Scheduled job that polls for un-processed BillingRecord rows) calls the
// listener again by re-publishing the event, or by calling the listener method directly,
// UUID.randomUUID() at listener entry generates UUID_B. ch_B alongside committed ch_A.
//
// In a @Transactional test, the @TransactionalEventListener(AFTER_COMMIT) never fires
// because the test transaction never commits. The test cannot observe this problem.
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void onChargeRequested(ChargeRequested event) {
// UUID generated here at each listener invocation.
// If this listener is called twice for the same event (retry), UUID_B is generated.
String idempotencyKey = UUID.randomUUID().toString();
BillingAttempt attempt = new BillingAttempt(
event.getCustomerId(), event.getAmountCents(),
event.getBillingPeriod(), idempotencyKey);
repository.save(attempt);
stripeClient.createPaymentIntent(
event.getStripeCustomerId(), event.getAmountCents(), idempotencyKey);
repository.markSucceeded(event.getCustomerId(), event.getBillingPeriod(),
attempt.getId());
}
}
// BillingServiceEventTest.java — test that misses the UUID-in-listener bug
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
@RecordApplicationEvents
class BillingServiceEventTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");
@DynamicPropertySource
static void configure(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired private BillingService billingService;
@Autowired ApplicationEvents applicationEvents; // @RecordApplicationEvents captures events
// Test is @Transactional — test transaction rolls back at end.
// billingService.chargeCustomer() publishes ChargeRequested inside the test transaction.
// ChargeRequested is published (into the synchronization registry).
// BUT: the test transaction rolls back → AFTER_COMMIT never fires → listener never runs.
//
// Developer's reasoning:
// "@RecordApplicationEvents captures events published in this test context.
// I assert the event payload is correct. The @TransactionalEventListener handles
// the Stripe call as a separate concern — I test the listener separately."
//
// The problem:
// The listener is never tested in this test.
// The UUID generated inside the listener on re-invocation is never tested anywhere.
@Test
@Transactional
void publishes_ChargeRequested_event_with_correct_payload() {
// Arrange.
String customerId = "cust_event_001";
// (insert customer row)
// Act.
billingService.chargeCustomer(customerId);
// Assert: event was published.
assertThat(applicationEvents.stream(ChargeRequested.class))
.hasSize(1)
.first()
.satisfies(event -> {
assertThat(event.getCustomerId()).isEqualTo(customerId);
assertThat(event.getAmountCents()).isEqualTo(1000L);
});
// This test NEVER verifies:
// - that ChargeEventListener ran
// - that Stripe was called
// - that the Idempotency-Key in the Stripe call is stable across re-invocations
// - that the key is carried in the event payload (not re-generated per invocation)
}
}
The developer writes a separate unit test for ChargeEventListener that calls the listener method directly. The unit test passes a ChargeRequested event with known fields and asserts WireMock was called. But the unit test calls the listener method directly, not through the event system and not through Spring transaction synchronization. The test sets up the listener with a mocked StripeClient that returns success. UUID is generated once, Stripe is called once, the test passes.
The unit test also does not exercise the retry scenario: what happens when the listener is called a second time for the same logical billing event (because an async scheduler retries events whose listener invocations were not confirmed). The UUID generated on the second invocation is UUID_B, different from UUID_A on the first invocation. ch_B alongside ch_A. Two charges.
The fix: carry the idempotency key in the event payload; use @Commit for listener tests
There are two parts to the fix. The first part changes the production code: the idempotency key must be computed before the event is published and carried as a field in the event payload. The listener receives a stable key. Every invocation of the listener with the same event object gets the same key. Re-invocations (retries) pass the same event object with the same key — the listener is idempotent.
// ChargeRequested.java — event carries stable idempotency key computed at publish time
public class ChargeRequested {
private final String customerId;
private final String stripeCustomerId;
private final long amountCents;
private final String billingPeriod;
// Key is computed in the publishing service, not in the listener.
// Content-hash key: same inputs always produce the same key.
private final String idempotencyKey;
// Constructor, getters...
}
// BillingService.java — key computed before publish, outside listener
@Transactional
public void chargeCustomer(String customerId) {
Customer customer = customerRepository.findById(customerId).orElseThrow();
// Content-hash key computed here, in the publishing service method.
// Same customer + billingPeriod + amountCents always produces the same key.
String idempotencyKey = Hashing.sha256()
.hashString(customerId + "|" + customer.getBillingPeriod() + "|" + customer.getAmountCents(),
StandardCharsets.UTF_8)
.toString().substring(0, 36);
BillingRecord record = new BillingRecord(customerId, customer.getAmountCents(),
customer.getBillingPeriod(), idempotencyKey);
billingRecordRepository.save(record);
// Key is in the event payload — listener receives it as a stable field.
applicationEventPublisher.publishEvent(new ChargeRequested(
customerId, customer.getStripeCustomerId(),
customer.getAmountCents(), customer.getBillingPeriod(), idempotencyKey));
}
// ChargeEventListener.java — listener uses event's key, never generates its own
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void onChargeRequested(ChargeRequested event) {
// Key from event payload — stable across any number of listener re-invocations.
String idempotencyKey = event.getIdempotencyKey();
BillingAttempt attempt = new BillingAttempt(
event.getCustomerId(), event.getAmountCents(),
event.getBillingPeriod(), idempotencyKey);
repository.save(attempt);
stripeClient.createPaymentIntent(
event.getStripeCustomerId(), event.getAmountCents(), idempotencyKey);
repository.markSucceeded(event.getCustomerId(), event.getBillingPeriod(), attempt.getId());
}
The second part fixes the test: use @Commit on the specific test method that exercises the full publish-then-listen flow, so the transaction commits and the AFTER_COMMIT listener fires. Clean up the database explicitly in a @BeforeEach method.
// BillingServiceListenerTest.java — correct test with @Commit for AFTER_COMMIT listener
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@WireMockTest(httpPort = 8090)
@Testcontainers
class BillingServiceListenerTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");
@DynamicPropertySource
static void configure(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
registry.add("stripe.base-url", () -> "http://localhost:8090");
}
@Autowired private BillingService billingService;
@Autowired private JdbcTemplate jdbc;
@BeforeEach
void cleanUp() {
// Explicit cleanup because this test uses @Commit — no rollback.
jdbc.execute("DELETE FROM billing_attempts");
jdbc.execute("DELETE FROM billing_records");
jdbc.execute("DELETE FROM customers WHERE id LIKE 'cust_listener_%'");
}
// @Commit: the test's transaction commits at end, not rolls back.
// This is required for @TransactionalEventListener(AFTER_COMMIT) to fire.
@Test
@Commit
@Transactional
void listenerFiresAfterCommitAndUsesStableIdempotencyKey() throws Exception {
// Arrange: insert customer.
jdbc.update("INSERT INTO customers (id, stripe_id, amount_cents, billing_period) " +
"VALUES (?, ?, ?, ?)", "cust_listener_001", "stripe_cus_listener", 3000L, "2026-10");
// Expected content-hash key for this input set.
String expectedKey = Hashing.sha256()
.hashString("cust_listener_001|2026-10|3000", StandardCharsets.UTF_8)
.toString().substring(0, 36);
// Arrange: WireMock stub for Stripe.
stubFor(post(urlEqualTo("/v1/payment_intents"))
.withHeader("Idempotency-Key", equalTo(expectedKey))
.willReturn(aResponse().withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"id\":\"pi_listener\",\"status\":\"succeeded\"}")));
// Act: call service (publishes event inside @Transactional).
billingService.chargeCustomer("cust_listener_001");
// After the test method exits, @Commit causes the transaction to commit.
// AFTER_COMMIT listener fires asynchronously.
// Wait briefly for async listener execution.
await().atMost(5, TimeUnit.SECONDS)
.untilAsserted(() -> {
// Assert: Stripe was called with the correct, stable idempotency key.
verify(1, postRequestedFor(urlEqualTo("/v1/payment_intents"))
.withHeader("Idempotency-Key", equalTo(expectedKey)));
// Assert: one BillingAttempt record.
Integer count = jdbc.queryForObject(
"SELECT COUNT(*) FROM billing_attempts WHERE customer_id = ?",
Integer.class, "cust_listener_001");
assertThat(count).isEqualTo(1);
});
}
// Separately: test that listener re-invocation with the same event uses the same key.
@Test
void listenerReInvocationIsIdempotent() throws Exception {
String customerId = "cust_listener_002";
String stripeCustomerId = "stripe_cus_listener_002";
long amountCents = 4000L;
String billingPeriod = "2026-10";
String expectedKey = Hashing.sha256()
.hashString(customerId + "|" + billingPeriod + "|" + amountCents,
StandardCharsets.UTF_8)
.toString().substring(0, 36);
ChargeRequested event = new ChargeRequested(
customerId, stripeCustomerId, amountCents, billingPeriod, expectedKey);
// Stub: WireMock expects the key on first call; returns 500.
// On second call (listener re-invoked), expects SAME key; returns 200.
stubFor(post(urlEqualTo("/v1/payment_intents"))
.withHeader("Idempotency-Key", equalTo(expectedKey))
.inScenario("listener-retry")
.whenScenarioStateIs(Scenario.STARTED)
.willReturn(aResponse().withStatus(500)
.withBody("{\"error\":{\"type\":\"api_error\"}}"))
.willSetStateTo("listener-second"));
stubFor(post(urlEqualTo("/v1/payment_intents"))
.withHeader("Idempotency-Key", equalTo(expectedKey))
.inScenario("listener-retry")
.whenScenarioStateIs("listener-second")
.willReturn(aResponse().withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"id\":\"pi_listener_ok\",\"status\":\"succeeded\"}")));
// Directly invoke the listener twice (simulating async retry mechanism).
// Both invocations receive the SAME event object with the SAME idempotencyKey field.
chargeEventListener.onChargeRequested(event); // attempt 1: Stripe returns 500
chargeEventListener.onChargeRequested(event); // attempt 2: Stripe returns 200
// Assert: both Stripe calls carried the same key.
List<LoggedRequest> requests = findAll(postRequestedFor(urlEqualTo("/v1/payment_intents")));
assertThat(requests).hasSize(2);
assertThat(requests.get(0).getHeader("Idempotency-Key")).isEqualTo(expectedKey);
assertThat(requests.get(1).getHeader("Idempotency-Key")).isEqualTo(expectedKey);
}
}
There is a subtlety with @Commit in Testcontainers tests: because the test transaction actually commits, the database rows inserted during the test persist after the test method exits. The @BeforeEach cleanup is essential. If you run multiple tests in the suite and forget the cleanup, later tests will see rows inserted by earlier tests. This is different from @Transactional tests where rollback handles cleanup automatically.
There is also a practical concern with @TransactionalEventListener and async execution. If the listener is not annotated with @Async, it executes synchronously during the transaction commit phase, before the committing thread returns to the test. In that case, the await().untilAsserted() in the test is unnecessary — the listener will have fired by the time the test method’s @Commit flush completes. If the listener is @Async, the test needs to wait for the listener’s thread to complete before making WireMock assertions. The await().atMost(5, TimeUnit.SECONDS) pattern with Awaitility handles this correctly.
Comparison: how each blind spot differs from the others
| Blind spot | Why the test passes (incorrectly) | What production failure it misses | What assertion is missing |
|---|---|---|---|
Mode 1: @Transactional test propagation |
Service joins test’s outer transaction; UUID generated once; test sees one call | Two concurrent HTTP requests each generate their own UUID; two independent transactions; ch_A and ch_B both committed | Concurrent assertion: two threads call the service simultaneously; assert one BillingAttempt row and one WireMock request |
| Mode 2: WireMock URL-only stub | WireMock stub matches URL and method; accepts UUID_B on retry attempt 2; returns 200 | @Retryable re-executes method body; attempt 2 sends UUID_B to Stripe; Stripe creates ch_B alongside ch_A |
WireMock request journal assertion: assertThat(keyAttempt1).isEqualTo(keyAttempt2) |
Mode 3: @TransactionalEventListener(AFTER_COMMIT) |
@Transactional test rolls back; AFTER_COMMIT listener never fires; test asserts event was published |
Listener generates UUID at each invocation; async retry re-invokes listener with UUID_B; ch_B alongside ch_A | @Commit on test method; WireMock stub with header matcher for expected content-hash key; assert key is identical on listener re-invocation |
The structural pattern across all three: the test validates that something happened (one DB row, WireMock returned 200, event was published) without validating the idempotency property (same key on every attempt, same outcome for the same inputs regardless of how many times the operation is triggered). A test can be “correct” — it exercises real code against a real database and a real-ish HTTP mock — and still miss the production bug if the assertion does not check the specific idempotency invariant that Stripe requires.
Detection signals in production
If tests have not caught the problem, these are the signals to watch for in production logs and Stripe dashboard data.
Stripe dashboard: duplicate PaymentIntents for the same customer within a short window. If you see two PaymentIntents for the same customer ID, same amount, and same description within seconds of each other, the Idempotency-Key headers were different. Navigate to the PaymentIntent detail page and inspect the “Idempotency key” field in the API request log section. Two distinct keys confirm the double-charge scenario.
Application logs: transaction IDs and retry counts together. Configure your logging context to include the Spring transaction ID (TransactionSynchronizationManager.getCurrentTransactionName()) and the Spring Retry attempt number (via a RetryListener that logs the current retry context). If the transaction name changes between retry attempt 1 and retry attempt 2, a new transaction was opened — and UUID generation at method entry ran again. If the transaction name stays the same across retries, the retry is happening inside the transaction boundary (the correct shape for retrying the Stripe call only).
Database audit: billing_attempts rows without a corresponding Stripe PaymentIntent ID. Mode 1 (UUID rolled back) leaves a gap: the MongoDB or PostgreSQL audit table may show only the UUID_B attempt (UUID_A was rolled back), while Stripe shows both ch_A and ch_B. A reconciliation query that joins your billing attempts table against a Stripe PaymentIntents export on the idempotency key field will show ch_A as an orphaned Stripe record with no corresponding local attempt record.
Metrics: billing call count per customer per billing period. In a functioning billing system, each customer should have exactly one successful charge per billing period. A Prometheus counter billing_charges_total{customer_id="...",billing_period="...",status="succeeded"} that reaches 2 for any (customer_id, billing_period) combination is the signal. Alert on billing_charges_total > 1 for the same label set.
What changes between the test patterns and production
All three blind spots share the same root cause: the test context changes the behavior of the code under test in a way that makes the idempotency-relevant code path unreachable or unobservable.
Mode 1 changes transaction scope. A @Transactional test method introduces an outer transaction that does not exist in production. The service’s REQUIRED propagation joins this outer transaction. The result is a single transaction for the entire test method, including all service calls. In production, each HTTP request is its own transaction. The test has collapsed the concurrent-request scenario into a single-request scenario.
Mode 2 changes the HTTP mock’s acceptance criteria. WireMock’s default URL-matching stub accepts any request to the registered path. The real Stripe API enforces idempotency key semantics: the same key returns the same result; different keys create different resources. The test’s stub behaves like a dumb echo server that always returns 200, not like Stripe’s actual deduplication engine. The test validates “did we call Stripe?” but not “did we call Stripe correctly?”
Mode 3 changes the transaction commit lifecycle. Spring’s @Transactional test integration unconditionally rolls back the test transaction. The AFTER_COMMIT contract requires a commit. No commit happens in a @Transactional test. The event listener — the component responsible for calling Stripe — is simply never invoked during the test. The test has no visibility into the listener’s behavior.
These are well-understood Spring framework semantics. They are documented. The issue is that they interact with billing code’s idempotency requirements in ways that are not immediately obvious from reading the test code alone. A passing test is assumed to mean “the code does what I think it does.” In each of these cases, the test passes because of how the test framework changes the code’s execution environment, not because the code is correct in the production execution environment.
Checklist: idempotency assertions for Spring Boot Testcontainers Stripe tests
For any test that exercises a Stripe-calling code path, verify these assertions are present:
- Header stability across retries. Call
findAll(postRequestedFor(urlEqualTo("/v1/payment_intents")))on the WireMock request journal. Assert all returnedLoggedRequestobjects have equalIdempotency-Keyheader values. This catches@RetryableUUID re-generation. - Single charge under concurrent requests. Use two threads calling the service simultaneously with the same logical billing inputs. Assert exactly one
BillingAttemptrow and one WireMock request. This catches per-request UUID generation without a content-hash key or database unique constraint. - Header value matches expected content-hash key. If you use content-hash keys, compute the expected key in the test from the billing inputs and assert
verify(postRequestedFor(...).withHeader("Idempotency-Key", equalTo(expectedKey))). This validates the key derivation logic is correct and deterministic. - Listener coverage with
@Commit. For code paths that call Stripe from a@TransactionalEventListener, use@Commiton the test method (not@Transactionalalone). Add explicit@BeforeEachcleanup. Assert the WireMock call was made with the expected key. - Listener re-invocation is idempotent. Call the listener directly twice with the same event object. Assert both WireMock calls carried the same
Idempotency-Keyvalue and that only oneBillingAttemptrow exists after both calls (upsert-by-idempotency-key pattern).
Related posts in this series
The production-side failure modes that these tests should catch are covered in detail in:
- Spring Boot
@Retryable+@Transactional+ Stripe: AOP proxy ordering,proceed()re-executes UUID at method entry - Spring Data JPA + Stripe:
EntityManagerlifecycle, JPA optimistic locking retry loops,@TransactionalEventListener+@RetryableUUID re-generation - Spring Data MongoDB + Stripe:
ReactiveMongoTransactionManagerreactive session lifecycle,ReactiveMongoTemplate.inTransaction()callback re-invocation, coldFluxbatch re-query - Project Reactor + Kotlin Flow + Stripe:
Mono.defer{}re-evaluation onretryWhen(), coldflow{}builder batch re-collection,TransactionalOperatorvs.@Transactional suspend fun - Quarkus Hibernate Reactive Panache + Stripe: CDI interceptor priority, Mutiny
item(Supplier)lazy supplier re-evaluation, PanachestreamAll()cold Multi re-stream
Stop UUID_B before it reaches production
Keybrake’s API-key proxy adds an idempotency enforcement layer between your service and Stripe. Duplicate Idempotency-Key variations for the same logical billing event are detected and blocked before the second request leaves your network. Get early access.