The full picture
// NOTE: no @Transactional on the test — we test REAL commit/rollback
@SpringBootTest
class TransferAtomicityIT extends AbstractIT {
@Test void failedCreditRollsBackDebit() {
given(ledgerPort.credit(any())).willThrow(new LedgerDownException());
assertThatThrownBy(() -> transferService.transfer(A, B, Money.of("100")))
.isInstanceOf(LedgerDownException.class);
assertThat(accountRepo.balance(A)).isEqualTo(Money.of("500")); // untouched ✅
assertThat(auditRepo.count()).isZero(); // no orphan rows
}
@Test void confirmationSentOnlyAfterCommit() {
transferService.transfer(A, B, Money.of("100"));
await().atMost(2, SECONDS).untilAsserted(() -> // async listener
assertThat(mailFake.sent()).hasSize(1));
}
@Test void confirmationNotSentWhenRolledBack() {
given(ledgerPort.credit(any())).willThrow(new LedgerDownException());
catchThrowable(() -> transferService.transfer(A, B, Money.of("100")));
await().during(1, SECONDS).untilAsserted(() -> // holds for the window
assertThat(mailFake.sent()).isEmpty()); // AFTER_COMMIT honored ✅
}
}- Why test-level @Transactional lies here: the test TX wraps your service call; 'rollback' is the test's rollback; REQUIRES_NEW paths commit inside it and are NOT rolled back; after-commit listeners never fire (no commit happens). Know exactly when to drop it.
- Awaitility over sleep:
await().atMost(…)polls to success (fast when fast, patient when slow);during(…)proves a negative for a window — the only honest way to assert 'did not happen'. - Deterministic async: for pure logic, replace the executor with a synchronous one (
SyncTaskExecutor) in a test profile; keep a few genuinely async tests for the real threading. - Idempotency/retry tests: fire the same command/event twice, assert single effect — the test that pays for itself the first week of Kafka redelivery.