Interview Prep Zoneby CuriouserLabs

Question 5 of 5 · architect level

How do you test transactional behavior, @Async and event-driven flows — the things that only break in production?

🎤 Say this first

Test the semantics, not the annotations. Rollback: a real test asserting 'after a mid-operation failure, no partial rows exist' — in a non-@Transactional test (a transactional test's rollback masks the behavior; and REQUIRES_NEW inside a test TX commits for real!). @Async/events: assert outcomes with Awaitility (poll until visible) — never Thread.sleep; for after-commit listeners, verify both sides: event fires on commit, does NOT fire on rollback. Time: inject Clock everywhere and control it. These tests are the executable documentation of your consistency model.

The full picture

Testing what @Transactional actually promises
// 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.

🔄 Likely follow-up questions

  • Why do after-commit listeners not fire in @Transactional tests?
  • How would you test the outbox pattern end-to-end?
  • What's your approach to testing scheduled jobs?