skip to content

How do you deterministically test time-triggered logic with the Spring Modulith TimeMachine?

level: middleimportance: should knowfreq 35%

answer

  1. enable-time-machine=true (test profile)
  2. inject TimeMachine, call shiftBy(Duration)
  3. synchronous publish of crossed boundaries
  4. one shift can cross many events
  5. async listeners → pair with Scenario.andWaitForStateChange

basics

~10 s

Enable 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 s

TimeMachine 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
java
@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

for a junior

Know you inject TimeMachine and call shiftBy to fast-forward instead of waiting.

for a middle

Enable via the property, understand that one shift publishes all crossed boundary events synchronously, and simulate long spans in one call.

for a senior

Handle async listeners by combining TimeMachine with Scenario's await helpers and understand it only affects the Moments/Clock abstraction.

for a principal

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

context