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.