TestClock — Deterministic Time in Tests

TestClock was introduced in Clock Injection from a scheduling perspective. This section focuses on how to use it in tests — specifically with run_test_with_clock and multi-step scenarios.

The Problem with Real Time in Tests

#![allow(unused)]
fn main() {
// This test takes 7 seconds to run
#[test]
fn retry_exhaustion_slow() {
    let eff = failing_call()
        .retry(Schedule::exponential(Duration::from_secs(1)).take(3));
    let exit = run_blocking(eff, ());
    assert!(matches!(exit, Exit::Failure(_)));
}
}

Multiply this by dozens of tests and your suite is unusable. TestClock makes it instant.

run_test_with_clock

#![allow(unused)]
fn main() {
use id_effect::{run_test_with_clock, TestClock};

#[test]
fn retry_exhaustion_fast() {
    let result = run_test_with_clock(|clock| {
        let eff = failing_call()
            .retry(Schedule::exponential(Duration::from_secs(1)).take(3));

        // Fork the effect
        let handle = eff.fork();

        // Drive time forward to trigger each retry
        clock.advance(Duration::from_secs(1));
        clock.advance(Duration::from_secs(2));
        clock.advance(Duration::from_secs(4));

        // Collect the result
        handle.join()
    });

    assert!(matches!(result, Exit::Failure(_)));
}
}

run_test_with_clock creates a TestClock, injects it into the effect environment, and calls your closure with a handle to the clock. You advance time; the runtime processes sleep effects that become due.

TestClock API

#![allow(unused)]
fn main() {
let clock = TestClock::new();

// Read the current (fake) time — starts at Unix epoch
let now: UtcDateTime = clock.now();

// Advance by a duration
clock.advance(Duration::from_millis(500));

// Jump to an absolute time
clock.set_time(UtcDateTime::from_unix_secs(1_700_000_000));

// How many sleeps are currently waiting?
let pending: usize = clock.pending_sleeps();
}

pending_sleeps() is useful in tests to assert that an effect is blocked on a timer rather than having silently completed or failed.

Testing Scheduled Work

#![allow(unused)]
fn main() {
#[test]
fn cron_job_runs_every_minute() {
    run_test_with_clock(|clock| {
        let counter = Arc::new(AtomicU32::new(0));
        let c = counter.clone();

        let job = effect!(|_r: &mut ()| {
            c.fetch_add(1, Ordering::Relaxed);
        })
        .repeat(Schedule::fixed(Duration::from_secs(60)));

        let _handle = job.fork();

        // Advance through 3 minutes
        clock.advance(Duration::from_secs(60));
        clock.advance(Duration::from_secs(60));
        clock.advance(Duration::from_secs(60));

        succeed(counter.load(Ordering::Relaxed))
    });

    // Verify 3 executions happened
}
}

Time and Race Conditions

TestClock is deterministic — time moves only when you call advance. This means tests that use TestClock have no time-based race conditions: the scheduler runs wake-up callbacks synchronously when you advance.

If your effect spawns multiple fibers that all sleep, advancing time wakes all fibers whose sleep deadline has passed, in a consistent order.

Combining TestClock with Fake Services

#![allow(unused)]
fn main() {
#[test]
fn rate_limiter_enforces_window() {
    let fake_store = InMemoryRateLimitStore::new();
    let env = ctx!(RateLimitStoreKey => Arc::new(fake_store));

    run_test_with_clock_and_env(env, |clock| {
        let eff = effect!(|_r: &mut Deps| {
            // Should succeed (first request in window)
            ~ check_rate_limit("alice");

            // Exhaust the limit
            for _ in 0..9 {
                ~ check_rate_limit("alice");
            }

            // Advance past the window
            // (clock advance happens outside the effect, so we fork here)
        });

        let handle = eff.fork();
        clock.advance(Duration::from_secs(61));
        handle.join()
    });
}
}

run_test_with_clock_and_env combines both: a controlled clock and a custom service environment.