Skip to main content

Testing with ManualClock

Drive retries and timeouts deterministically in tests, with resiliencia-test's ManualClock, instead of real Thread.sleep delays.

Every pattern that waits — Retry's backoff, RateLimiter's window, CircuitBreaker's waitDurationInOpenState — reads time through a Clock SPI instead of calling Instant.now() or Thread.sleep directly. Swap in ManualClock and a test that would otherwise take real minutes runs instantly, with time advanced under your control.

Add the dependency

pom.xml
<dependency>
<groupId>io.github.teceli</groupId>
<artifactId>resiliencia-test</artifactId>
<version>1.0.0-beta.1</version>
<scope>test</scope>
</dependency>

Create a clock

ManualClock.create() starts at a fixed, arbitrary instant. ManualClock.startingAt(instant) lets you pick that starting point yourself, useful when a test's assertions read more naturally against a specific date:

import io.github.teceli.resiliencia.test.ManualClock;

var clock = ManualClock.create();

Plug it into a pattern

Every pattern that accepts a Clock exposes it via withClock(...):

var attempts = new AtomicInteger(0);
var retry = Retry.<String>create("retry-under-test")
.withMaxAttempts(3)
.withInitialDelay(60_000)
.withShouldRetry(e -> true)
.withClock(clock);

var result = retry.call(() -> {
if (attempts.incrementAndGet() < 3) {
throw new RuntimeException("simulated failure");
}
return "recovered";
});

assertThat(result).isEqualTo("recovered");

Notice the one-minute initialDelay: on a real clock this test would take at least two minutes to run (two backoff waits). Against ManualClock, it completes immediately — ManualClock's sleep(millis) doesn't block, it just advances the clock and returns, so the pattern's own backoff wait costs nothing in wall-clock time.

Advance time manually

Not every wait is triggered by the pattern itself — sometimes the test needs to move time forward between two calls, e.g. to cross a RateLimiter's refresh period. Use advance(Duration):

var limiter = RateLimiter.<String>of("limiter", 1, Duration.ofSeconds(1)).withClock(clock);

limiter.call(() -> "first call"); // consumes the only permit in this period
clock.advance(Duration.ofSeconds(1)); // move past the period boundary
limiter.call(() -> "second call"); // a new period, a new permit

advance rejects a negative duration and moves the clock forward only — there's no way to move it backwards, keeping every test's timeline monotonic like a real clock's.