Explain the Spring Modulith Scenario API for testing asynchronous inter-module event flows.
answer
- stimulate → wait → verify
- andWaitForEventOfType / andWaitForStateChange
- toArriveAndVerify / andVerify
- Awaitility polling, no Thread.sleep
- matchingMappedValue to pick the right event
basics
~20 sScenario is a fluent, Awaitility-backed test API (injected into @ApplicationModuleTest methods) that stimulates an action or publishes an event, then waits for an expected event to arrive or a state change to occur before verifying — removing flaky sleeps in async, event-driven tests.
solid answer
~40 sScenario is Spring Modulith's fluent API for testing asynchronous choreography. You inject it as a test-method parameter in an @ApplicationModuleTest. You start with scenario.stimulate(runnable) (trigger some code) or scenario.publish(event), then declare what you're waiting for: andWaitForEventOfType(SomeEvent.class) to await a published event, or andWaitForStateChange(() -> repository.findById(id)) to poll until a supplier returns a present/non-null value. You finish with a verification: toArriveAndVerify(evt -> ...), toArrive(), or andVerify((result, events) -> ...). Under the hood it uses Awaitility to poll within a timeout, so tests are deterministic without Thread.sleep. You can narrow matches with matchingMappedValue(...)/matching(...) and tune waits with customize(...)/andWaitAtMost(Duration). This is essential because @ApplicationModuleListener handlers are async and after-commit, so a plain assertion right after the trigger would race.
code
java · 26 lines@ApplicationModuleTest
class CheckoutFlowTests {
@Autowired CheckoutService checkout;
@Autowired OrderRepository orders;
@Test
void publishesOrderPlacedAndPersists(Scenario scenario) {
var cmd = new Checkout("cart-1", 3);
scenario.stimulate(() -> checkout.submit(cmd))
// wait for the async event, matched to our cart
.andWaitForEventOfType(OrderPlaced.class)
.matchingMappedValue(OrderPlaced::cartId, "cart-1")
.toArriveAndVerify(evt ->
assertThat(orders.findById(evt.orderId())).isPresent());
}
@Test
void reactsToStateChange(Scenario scenario) {
scenario.stimulate(() -> checkout.submit(new Checkout("cart-2", 1)))
.andWaitForStateChange(() -> orders.findByCart("cart-2"))
.andWaitAtMost(Duration.ofSeconds(5))
.andVerify(o -> assertThat(o).isPresent());
}
}go deeper
Know it's a helper that waits for async results in module tests instead of sleeping.
Use stimulate → andWaitForEventOfType/andWaitForStateChange → verify correctly and know it's Awaitility-backed.
Match specific events with matchingMappedValue, tune timeouts, and pair with TimeMachine for time-triggered flows; explain the async/after-commit race it solves.
Standardise event-flow test patterns across modules, set sane CI timeouts, and use Scenario to make async choreography a verifiable contract rather than a flaky test.
## Why it exists In a modular monolith, modules communicate largely through **asynchronous application events** (e.g. `@ApplicationModuleListener`, which is `@Async` + `@TransactionalEventListener(AFTER_COMMIT)`). Testing such flows naively — call the method, then immediately assert — is **flaky**, because the listener runs on another thread *after* the transaction commits. People reach for `Thread.sleep`, which is slow and unreliable. `Scenario` replaces that with a deterministic, Awaitility-backed DSL. ## How you get it Declare a `Scenario` parameter on a test method inside an `@ApplicationModuleTest` (Modulith resolves it): ```java @ApplicationModuleTest class OrderTests { @Test void completesOrder(Scenario scenario) { ... } } ``` ## The three phases A Scenario reads as **stimulate → wait → verify**: ### 1. Stimulate (the trigger) - `scenario.stimulate(Runnable)` / `stimulate(Function<...>)` — run arbitrary code (e.g. call a service, or `timeMachine.shiftBy(...)`). - `scenario.publish(Object event)` — publish an application event directly. ### 2. Wait (the condition) - `.andWaitForEventOfType(Class<T>)` — wait until an event of that type is published by the system as a *result*. Refine with: - `.matching(Predicate)` or `.matchingMappedValue(mapper, expectedValue)` to select a specific event. - `.andWaitForStateChange(Supplier<T>)` — repeatedly evaluate the supplier (e.g. a repository lookup) until it returns a **present/non-null/non-empty** value, indicating the side effect happened. - Tuning: `.andWaitAtMost(Duration)` sets the timeout; `.customize(Function)` adjusts the underlying Awaitility `ConditionFactory` (poll interval, etc.). ### 3. Verify (the assertion) - `.toArrive()` — just require the awaited event to appear. - `.toArriveAndVerify(Consumer<T>)` — event appeared; assert on it. - `.andVerify(Consumer<T>)` / `.andVerify(BiConsumer<T, U>)` — after a state change, assert on the changed value (and optionally the events/received value). - `.andVerifyEvents(...)` — assert on the collection of matched events. ## Worked example ```java @Test void inventoryReservedAfterOrderPlaced(Scenario scenario) { scenario.stimulate(() -> orders.place(order)) .andWaitForEventOfType(InventoryReserved.class) .matchingMappedValue(InventoryReserved::orderId, order.id()) .toArriveAndVerify(evt -> assertThat(evt.quantity()).isEqualTo(3)); } @Test void draftExpiresNextDay(Scenario scenario) { scenario.stimulate(() -> timeMachine.shiftBy(Duration.ofDays(1))) .andWaitForStateChange(() -> drafts.findExpired()) .andVerify(expired -> assertThat(expired).isNotEmpty()); } ``` ## Semantics and gotchas - **Awaitility, not sleep**: it polls until the condition holds or the timeout fires (then the test fails). Choose realistic timeouts for CI. - **State-change condition = 'truthy'**: `andWaitForStateChange` waits until the supplier returns non-null / non-empty `Optional` / non-empty collection. If your success state is 'a field flips to true', map to that so the condition is meaningful. - **Event must actually be published**: `andWaitForEventOfType` only sees events published within the application context; if the flow never publishes it, the test times out — which correctly signals a broken flow. - **Ordering / multiple events**: use `.matching(...)`/`.matchingMappedValue(...)` so you await the *right* event, not just any of that type. - **Pairs with TimeMachine**: for Moments-driven flows, stimulate with `timeMachine.shiftBy(...)` and wait for the resulting state change — the canonical pattern for testing time-triggered async logic. - **Only inside @ApplicationModuleTest**: `Scenario` is resolved by Modulith's test bootstrap; it isn't available in a plain `@SpringBootTest` unless the Modulith test support is on the classpath and active. ## When to use Reach for Scenario whenever the outcome you assert is produced **asynchronously** — event listeners, after-commit handlers, Moments reactions. For fully synchronous logic a direct assertion is simpler and clearer.
- Why is Scenario preferable to Thread.sleep for async event tests?It polls with Awaitility until the condition is actually met (up to a timeout), so tests are both faster (they proceed the instant the outcome appears) and deterministic (no guessing a sleep duration that's flaky under load).
- andWaitForEventOfType(X.class) sometimes matches the wrong event. How do you make it precise?Chain .matching(predicate) or .matchingMappedValue(mapper, expectedValue) to select the specific event instance (e.g. the one whose orderId matches the order under test).
- What does andWaitForStateChange consider a satisfied condition?It polls the supplier until it returns a 'present' value — non-null, a non-empty Optional, or a non-empty collection — then proceeds to andVerify. Map to the exact success state so the wait is meaningful.
saying these in an interview costs you the question
- Claiming Scenario runs listeners synchronously (it waits for async ones)
- Using Thread.sleep instead and calling it equivalent
- Thinking andWaitForStateChange verifies equality rather than just presence/non-emptiness
- Believing Scenario works in any @SpringBootTest without Modulith test support