How do you deterministically test time-triggered logic with the Spring Modulith TimeMachine?
answer
- enable-time-machine=true (test profile)
- inject TimeMachine, call shiftBy(Duration)
- synchronous publish of crossed boundaries
- one shift can cross many events
- async listeners → pair with Scenario.andWaitForStateChange
basics
~10 sEnable the TimeMachine (spring.modulith.moments.enable-time-machine=true), inject it into the test, and call shiftBy(Duration) to fast-forward. Shifting past a boundary synchronously publishes the corresponding Moments events, so you can assert the reaction immediately.
solid answer
~40 sTimeMachine is a test double for Moments that lets you advance simulated time instead of waiting. Enable it with spring.modulith.moments.enable-time-machine=true (typically in a test profile), then autowire TimeMachine and call shiftBy(Duration.ofDays(1)) or shiftBy of any TemporalAmount. Advancing past hour/day/week/etc. boundaries publishes the matching HourHasPassed/DayHasPassed/... events synchronously in the calling thread, so a 'runs once a day' listener fires instantly and deterministically. You then assert the side effect (repository state, sent digest) right after the shift. It pairs naturally with @ApplicationModuleTest: inject TimeMachine alongside a Scenario or PublishedEvents. The key benefits are speed (no real waiting), determinism (no clock flakiness), and the ability to simulate long spans — shift a year forward in one call.
code
java · 19 lines@ApplicationModuleTest
@TestPropertySource(properties = "spring.modulith.moments.enable-time-machine=true")
class MonthlyRollupTests {
@Autowired TimeMachine timeMachine;
@Autowired RollupRepository rollups;
@Test
void createsAMonthlyRollupWhenAMonthPasses() {
timeMachine.shiftBy(Period.ofMonths(1)); // publishes MonthHasPassed (+ finer boundaries)
assertThat(rollups.findAll()).hasSize(1);
}
@Test
void firesYearlyLogicWithoutWaiting() {
timeMachine.shiftBy(Duration.ofDays(366)); // crosses one YearHasPassed
assertThat(rollups.yearlyCount()).isEqualTo(1);
}
}go deeper
Know you inject TimeMachine and call shiftBy to fast-forward instead of waiting.
Enable via the property, understand that one shift publishes all crossed boundary events synchronously, and simulate long spans in one call.
Handle async listeners by combining TimeMachine with Scenario's await helpers and understand it only affects the Moments/Clock abstraction.
Standardise time injection (Clock/Moments) so TimeMachine drives all time reads, and set test zone policy to avoid boundary ambiguity.
## The problem it solves Time-driven code is notoriously hard to test: you cannot wait a real hour or day in a unit test, and mocking the system clock everywhere is tedious. Spring Modulith's **`TimeMachine`** solves this specifically for **Moments** events. ## Enabling it `TimeMachine` is only registered when you set: ```properties spring.modulith.moments.enable-time-machine=true ``` Put this in your **test** configuration/profile (e.g. `application-test.properties` or `@TestPropertySource`). In production you leave it off so the real clock drives Moments. ## The API `TimeMachine` extends the `Moments` abstraction and adds the ability to move time: - `shiftBy(Duration duration)` — advance by a `Duration` (hours, days...). - `shiftBy(TemporalAmount amount)` — advance by any `TemporalAmount`, e.g. `Period.ofMonths(1)`. Calling `shiftBy` recomputes which time boundaries were crossed between the old and new simulated 'now' and **synchronously publishes every crossed event in order** (finest to coarsest). Because publication is synchronous on the calling thread, by the time `shiftBy` returns, all listeners have already run (respecting their transaction/async config — see gotchas). ## Typical test shape ```java @ApplicationModuleTest @TestPropertySource(properties = "spring.modulith.moments.enable-time-machine=true") class DraftExpiryTests { @Autowired TimeMachine timeMachine; @Autowired DraftRepository drafts; @Test void expiresOldDraftsAfterADay() { var draft = drafts.save(new Draft(/* created 'now' */)); timeMachine.shiftBy(Duration.ofDays(1)); // fires DayHasPassed (+ hourly boundaries) assertThat(drafts.findById(draft.getId())).map(Draft::isExpired).contains(true); } } ``` ## Simulating long spans One call can cross many boundaries: `timeMachine.shiftBy(Period.ofDays(400))` fires ~400 `DayHasPassed`, ~57 `WeekHasPassed`, 13 `MonthHasPassed`, a few `QuarterHasPassed` and one `YearHasPassed`, in order. This lets you test monthly/quarterly/yearly logic instantly. ## Interaction with async listeners If your listener is a plain `@EventListener`, it runs synchronously inside `shiftBy` and you can assert immediately. If it is `@Async` (e.g. via `@ApplicationModuleListener`, which is async + after-commit), the reaction runs on another thread/after commit, so a raw assertion right after `shiftBy` can race. In that case combine the TimeMachine with the **Scenario** API's `andWaitForStateChange(...)` / `andWaitForEventOfType(...)`, which use Awaitility to poll until the async outcome appears. ## Gotchas - Forgetting `enable-time-machine=true` means no `TimeMachine` bean → autowiring fails. - Boundary crossings depend on `zone-id`; a shift that looks like it crosses midnight may not in another zone. - The TimeMachine's notion of 'now' is what `Moments.now()` returns for the whole context, so any code reading time via `Moments` also sees the shifted time — but code reading `Instant.now()`/`LocalDate.now()` directly does **not** (it bypasses Moments). Inject the `Clock`/`Moments` abstraction if you need consistency. - Shifts are cumulative and forward-only in normal use; you don't rewind time.
- Your listener is @Async and the assertion right after shiftBy is flaky. What do you change?Don't assert immediately. Use the Scenario API (scenario.stimulate(() -> timeMachine.shiftBy(...)).andWaitForStateChange(() -> repo.find(...)).andVerify(...)) which polls with Awaitility until the async reaction completes.
- Code that reads LocalDate.now() directly ignores your TimeMachine shift. Why?TimeMachine only drives the Moments abstraction's notion of time. Direct calls to LocalDate.now()/Instant.now() read the real system clock. Inject a Clock or the Moments bean so all time reads see the shifted value.
saying these in an interview costs you the question
- Thinking shiftBy also rewinds or resets time by default
- Assuming TimeMachine changes what LocalDate.now() returns globally
- Expecting @Async listeners to have completed synchronously right after shiftBy
- Forgetting the enable-time-machine property and wondering why the bean is missing