Skip to main content

effectful/testing/
test_runtime.rs

1//! Deterministic test runtime harness helpers.
2//!
3//! Prefer effect-returning tests over calling [`Effect::run`](crate::Effect::run) in test bodies.
4//! The adapter owns execution, environment creation, hygiene checks, and failure formatting.
5//!
6//! ```rust
7//! use effectful::{Effect, effect_test};
8//!
9//! #[effect_test]
10//! fn succeeds() -> Effect<(), &'static str, ()> {
11//!   Effect::new(|_| Ok(()))
12//! }
13//! ```
14//!
15//! `#[effect_test]` expands to a current-thread Tokio test through `effectful::testing`, so
16//! downstream crates do not need to name `tokio` in each test body.
17//!
18//! Provide a service context fixture with `env = "path"`:
19//!
20//! ```rust
21//! use effectful::{Effect, Service, ServiceContext, effect_test};
22//!
23//! #[derive(Clone, Service)]
24//! struct Clock { value: u64 }
25//!
26//! fn test_env() -> ServiceContext {
27//!   Clock { value: 1 }.to_context()
28//! }
29//!
30//! #[effect_test(env = "test_env")]
31//! fn uses_context() -> Effect<(), effectful::MissingService, ServiceContext> {
32//!   Effect::service::<Clock>().map(|clock| assert_eq!(clock.value, 1))
33//! }
34//! ```
35
36use crate::layer::Layer;
37use crate::runtime::Never;
38use crate::{Effect, Exit, ServiceContext, TestClock};
39use std::cell::{Cell, RefCell};
40use std::fmt::Debug;
41
42thread_local! {
43  static LEAKED_FIBERS: Cell<usize> = const { Cell::new(0) };
44  static UNCLOSED_SCOPES: Cell<usize> = const { Cell::new(0) };
45  static ACTIVE_TEST_CLOCK: RefCell<Option<TestClock>> = const { RefCell::new(None) };
46}
47
48struct TestClockScope {
49  previous: Option<TestClock>,
50}
51
52impl Drop for TestClockScope {
53  fn drop(&mut self) {
54    let previous = self.previous.clone();
55    ACTIVE_TEST_CLOCK.with(|clock| {
56      *clock.borrow_mut() = previous;
57    });
58  }
59}
60
61fn install_test_clock(clock: TestClock) -> TestClockScope {
62  let previous = ACTIVE_TEST_CLOCK.with(|active| active.borrow_mut().replace(clock));
63  TestClockScope { previous }
64}
65
66pub(crate) fn current_test_clock() -> Option<TestClock> {
67  ACTIVE_TEST_CLOCK.with(|clock| clock.borrow().clone())
68}
69
70fn reset_counters() {
71  LEAKED_FIBERS.with(|c| c.set(0));
72  UNCLOSED_SCOPES.with(|c| c.set(0));
73}
74
75fn assert_hygiene_counters() {
76  let fiber_leaks = LEAKED_FIBERS.with(|c| c.get());
77  assert_eq!(
78    fiber_leaks, 0,
79    "deterministic test harness detected leaked fibers: {fiber_leaks}"
80  );
81
82  let scope_leaks = UNCLOSED_SCOPES.with(|c| c.get());
83  assert_eq!(
84    scope_leaks, 0,
85    "deterministic test harness detected unclosed scopes: {scope_leaks}"
86  );
87}
88
89/// Internal hook for tests that need to simulate leaked fibers.
90pub fn record_leaked_fiber() {
91  LEAKED_FIBERS.with(|c| c.set(c.get().saturating_add(1)));
92}
93
94/// Internal hook for tests that need to simulate unclosed scopes.
95pub fn record_unclosed_scope() {
96  UNCLOSED_SCOPES.with(|c| c.set(c.get().saturating_add(1)));
97}
98
99/// Assert that no leaked fibers were recorded in the current test harness run.
100pub fn assert_no_leaked_fibers() -> Effect<(), Never, ()> {
101  Effect::new(move |_env| {
102    let leaks = LEAKED_FIBERS.with(|c| c.get());
103    assert_eq!(
104      leaks, 0,
105      "deterministic test harness detected leaked fibers: {leaks}"
106    );
107    Ok(())
108  })
109}
110
111/// Assert that no unclosed scopes were recorded in the current test harness run.
112pub fn assert_no_unclosed_scopes() -> Effect<(), Never, ()> {
113  Effect::new(move |_env| {
114    let leaks = UNCLOSED_SCOPES.with(|c| c.get());
115    assert_eq!(
116      leaks, 0,
117      "deterministic test harness detected unclosed scopes: {leaks}"
118    );
119    Ok(())
120  })
121}
122
123/// Small async runtime adapter for effect-returning tests.
124///
125/// `TestRuntime` lets downstream crates keep direct effect execution at the test harness edge.
126/// Use [`TestRuntime::default`] for `R: Default`, or [`TestRuntime::with_env`] to provide a
127/// context/fixture per test run.
128pub struct TestRuntime<R, F = fn() -> R>
129where
130  R: 'static,
131  F: FnOnce() -> R,
132{
133  make_env: F,
134}
135
136impl<R> Default for TestRuntime<R>
137where
138  R: Default + 'static,
139{
140  fn default() -> Self {
141    Self {
142      make_env: R::default,
143    }
144  }
145}
146
147impl<R, F> TestRuntime<R, F>
148where
149  R: 'static,
150  F: FnOnce() -> R,
151{
152  /// Create a test runtime from an environment fixture.
153  #[inline]
154  pub fn with_env(make_env: F) -> Self {
155    Self { make_env }
156  }
157
158  /// Run an effect under the test harness and return its result.
159  #[inline]
160  pub async fn run<A, E>(self, effect: Effect<A, E, R>) -> Result<A, E>
161  where
162    A: 'static,
163    E: 'static,
164  {
165    let env = (self.make_env)();
166    run_effect_test_with_env(effect, env).await
167  }
168
169  /// Run an effect under the test harness and panic with `Debug` output on failure.
170  #[inline]
171  pub async fn expect<A, E>(self, effect: Effect<A, E, R>) -> A
172  where
173    A: 'static,
174    E: Debug + 'static,
175  {
176    expect_effect_test_with_env(effect, (self.make_env)()).await
177  }
178}
179
180/// Run an effect-returning test with a default environment.
181#[inline]
182pub async fn run_effect_test<A, E, R>(effect: Effect<A, E, R>) -> Result<A, E>
183where
184  A: 'static,
185  E: 'static,
186  R: Default + 'static,
187{
188  run_effect_test_with_env(effect, R::default()).await
189}
190
191/// Run an effect-returning test with a provided environment.
192#[inline]
193pub async fn run_effect_test_with_env<A, E, R>(effect: Effect<A, E, R>, mut env: R) -> Result<A, E>
194where
195  A: 'static,
196  E: 'static,
197  R: 'static,
198{
199  reset_counters();
200  let result = effect.run(&mut env).await;
201  assert_hygiene_counters();
202  result
203}
204
205/// Run an effect-returning test with a default environment and panic on failure.
206#[inline]
207pub async fn expect_effect_test<A, E, R>(effect: Effect<A, E, R>) -> A
208where
209  A: 'static,
210  E: Debug + 'static,
211  R: Default + 'static,
212{
213  expect_effect_test_with_env(effect, R::default()).await
214}
215
216/// Run an effect-returning test with a provided environment and panic on failure.
217#[inline]
218pub async fn expect_effect_test_with_env<A, E, R>(effect: Effect<A, E, R>, env: R) -> A
219where
220  A: 'static,
221  E: Debug + 'static,
222  R: 'static,
223{
224  match run_effect_test_with_env(effect, env).await {
225    Ok(value) => value,
226    Err(error) => panic!("effectful test failed: {error:?}"),
227  }
228}
229
230/// Run a `ServiceContext` effect-returning test with a layer and panic on failure.
231#[inline]
232pub async fn expect_effect_test_with_layer<A, E, ROut>(
233  effect: Effect<A, E, ServiceContext>,
234  layer: Layer<ROut, E, ()>,
235) -> A
236where
237  A: 'static,
238  E: Debug + 'static,
239  ROut: 'static,
240{
241  expect_effect_test(effect.provide(layer)).await
242}
243
244/// Run an effect in deterministic test mode and return an `Exit` value.
245#[inline]
246pub fn run_test<A, E, R>(effect: Effect<A, E, R>, env: R) -> Exit<A, E>
247where
248  A: 'static,
249  E: 'static,
250  R: 'static,
251{
252  reset_counters();
253  let result = crate::runtime::run_blocking(effect, env);
254  assert_hygiene_counters();
255  match result {
256    Ok(value) => Exit::succeed(value),
257    Err(error) => Exit::fail(error),
258  }
259}
260
261/// Run an effect in deterministic test mode with an explicit test clock.
262#[inline]
263pub fn run_test_with_clock<A, E, R>(effect: Effect<A, E, R>, env: R, clock: TestClock) -> Exit<A, E>
264where
265  A: 'static,
266  E: 'static,
267  R: 'static,
268{
269  let _scope = install_test_clock(clock);
270  run_test(effect, env)
271}
272
273#[cfg(test)]
274mod tests {
275  use super::*;
276  use crate::scheduling::duration::duration;
277  use crate::{
278    Metric, MissingService, Schedule, ScheduleInput, fail, retry, retry_with_clock, succeed,
279  };
280  use rstest::rstest;
281  use std::sync::Arc;
282  use std::sync::Mutex;
283  use std::sync::atomic::{AtomicUsize, Ordering};
284
285  #[derive(Clone, Debug, PartialEq, effectful::Service)]
286  struct TestService {
287    value: u32,
288  }
289
290  struct TestFailure {
291    code: u32,
292  }
293
294  impl std::fmt::Debug for TestFailure {
295    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
296      f.debug_struct("TestFailure")
297        .field("code", &self.code)
298        .finish()
299    }
300  }
301
302  fn service_context() -> ServiceContext {
303    TestService { value: 9 }.to_context()
304  }
305
306  fn service_layer() -> Layer<TestService, MissingService, ()> {
307    Layer::succeed(TestService { value: 11 })
308  }
309
310  mod run_test {
311    use super::*;
312
313    #[test]
314    fn run_test_with_success_effect_returns_success_exit() {
315      let exit = run_test(succeed::<u32, (), ()>(7), ());
316      assert_eq!(exit, Exit::succeed(7));
317    }
318
319    #[test]
320    fn run_test_with_failure_effect_returns_failure_exit() {
321      let exit = run_test(fail::<(), &'static str, ()>("boom"), ());
322      assert_eq!(exit, Exit::fail("boom"));
323    }
324
325    #[rstest]
326    #[case::zero(0u8)]
327    #[case::positive(9u8)]
328    fn run_test_with_clock_matches_run_test_semantics_for_successful_effect(#[case] value: u8) {
329      let effect = succeed::<u8, (), ()>(value);
330      let clock = TestClock::new(std::time::Instant::now());
331      let exit = run_test_with_clock(effect, (), clock);
332      assert_eq!(exit, Exit::succeed(value));
333    }
334
335    #[test]
336    fn run_test_with_clock_drives_retry_schedule_sleep_without_wall_clock_wait() {
337      let start = std::time::Instant::now();
338      let clock = TestClock::new(start);
339      let attempts = Arc::new(AtomicUsize::new(0));
340      let attempts_c = Arc::clone(&attempts);
341      let effect = retry(
342        move || {
343          let attempt = attempts_c.fetch_add(1, Ordering::SeqCst);
344          if attempt == 0 {
345            fail::<usize, &'static str, ()>("boom")
346          } else {
347            succeed::<usize, &'static str, ()>(attempt + 1)
348          }
349        },
350        Schedule::spaced(duration::millis(50)).compose(Schedule::recurs(1)),
351      );
352
353      let before = std::time::Instant::now();
354      let exit = run_test_with_clock(effect, (), clock.clone());
355      let elapsed = before.elapsed();
356
357      assert_eq!(exit, Exit::succeed(2));
358      assert!(
359        elapsed < duration::millis(25),
360        "retry waited on wall clock for {elapsed:?}"
361      );
362      assert_eq!(clock.pending_sleeps(), vec![start + duration::millis(50)]);
363    }
364
365    #[test]
366    fn run_test_with_clock_retry_composed_schedule_uses_attempt_inputs_and_test_clock() {
367      let start = std::time::Instant::now();
368      let clock = TestClock::new(start);
369      let counter = Metric::counter("retry_composed_attempts", []);
370
371      let predicate_attempts = Arc::new(Mutex::new(Vec::new()));
372      let contramap_attempts = Arc::new(Mutex::new(Vec::new()));
373
374      let predicate_attempts_c = Arc::clone(&predicate_attempts);
375      let contramap_attempts_c = Arc::clone(&contramap_attempts);
376
377      let attempts = Arc::new(AtomicUsize::new(0));
378      let attempts_c = Arc::clone(&attempts);
379
380      let effect = retry_with_clock(
381        move || {
382          let n = attempts_c.fetch_add(1, Ordering::SeqCst);
383          if n < 2 {
384            fail::<usize, &'static str, ()>("boom")
385          } else {
386            succeed::<usize, &'static str, ()>(n + 1)
387          }
388        },
389        Schedule::spaced(duration::millis(50))
390          .compose(Schedule::recurs_while({
391            let attempts = Arc::clone(&predicate_attempts_c);
392            Box::new(move |i: &ScheduleInput| {
393              attempts.lock().expect("mutex poisoned").push(i.attempt);
394              i.attempt < 2
395            })
396          }))
397          .compose(
398            Schedule::recurs_until(Box::new(|i: &ScheduleInput| i.attempt >= 12)).contramap({
399              let attempts = Arc::clone(&contramap_attempts_c);
400              move |mut i: ScheduleInput| {
401                attempts.lock().expect("mutex poisoned").push(i.attempt);
402                i.attempt = i.attempt.saturating_add(10);
403                i
404              }
405            }),
406          ),
407        TestClock::new(start),
408        Some(counter.clone()),
409      );
410
411      let before = std::time::Instant::now();
412      let exit = run_test_with_clock(effect, (), clock.clone());
413      let elapsed = before.elapsed();
414
415      assert_eq!(exit, Exit::succeed(3));
416      assert_eq!(attempts.load(Ordering::SeqCst), 3);
417      assert_eq!(counter.snapshot_count(), 3);
418
419      let pred_seen = predicate_attempts.lock().expect("mutex poisoned");
420      assert_eq!(*pred_seen, vec![0, 1]);
421
422      let contra_seen = contramap_attempts.lock().expect("mutex poisoned");
423      assert_eq!(*contra_seen, vec![0, 1]);
424
425      assert_eq!(
426        clock.pending_sleeps(),
427        vec![start + duration::millis(50), start + duration::millis(50)]
428      );
429
430      assert!(
431        elapsed < duration::millis(25),
432        "retry waited on wall clock for {elapsed:?}"
433      );
434    }
435  }
436
437  mod effect_test_attribute {
438    use super::*;
439
440    #[crate::effect_test]
441    fn effect_returning_test_with_unit_environment_passes() -> Effect<(), &'static str, ()> {
442      Effect::new(|_| Ok(()))
443    }
444
445    #[crate::effect_test(env = "service_context")]
446    fn effect_returning_test_with_provided_context_passes()
447    -> Effect<(), MissingService, ServiceContext> {
448      Effect::<TestService, MissingService, ServiceContext>::service::<TestService>()
449        .map(|service| assert_eq!(service.value, 9))
450    }
451
452    #[crate::effect_test(layer = "service_layer")]
453    fn effect_returning_test_with_provided_layer_passes()
454    -> Effect<(), MissingService, ServiceContext> {
455      Effect::<TestService, MissingService, ServiceContext>::service::<TestService>()
456        .map(|service| assert_eq!(service.value, 11))
457    }
458  }
459
460  mod async_harness {
461    use super::*;
462
463    #[tokio::test]
464    async fn run_effect_test_with_env_returns_success_value() {
465      let effect = Effect::<u32, MissingService, ServiceContext>::service::<TestService>()
466        .map(|service| service.value);
467
468      let result = run_effect_test_with_env(effect, service_context()).await;
469
470      assert_eq!(result, Ok(9));
471    }
472
473    #[tokio::test]
474    async fn test_runtime_with_env_returns_success_value() {
475      let effect = Effect::<u32, MissingService, ServiceContext>::service::<TestService>()
476        .map(|service| service.value);
477
478      let result = TestRuntime::with_env(service_context).run(effect).await;
479
480      assert_eq!(result, Ok(9));
481    }
482
483    #[tokio::test]
484    #[should_panic(expected = "effectful test failed: TestFailure { code: 7 }")]
485    async fn expect_effect_test_with_failure_formats_debug_error() {
486      expect_effect_test(fail::<(), TestFailure, ()>(TestFailure { code: 7 })).await;
487    }
488  }
489
490  mod assertions {
491    use super::*;
492
493    #[test]
494    #[should_panic(expected = "deterministic test harness detected leaked fibers")]
495    fn assert_no_leaked_fibers_when_leaked_fiber_recorded_panics() {
496      record_leaked_fiber();
497      let _ = crate::runtime::run_blocking(assert_no_leaked_fibers(), ());
498    }
499
500    #[test]
501    #[should_panic(expected = "deterministic test harness detected unclosed scopes")]
502    fn assert_no_unclosed_scopes_when_unclosed_scope_recorded_panics() {
503      record_unclosed_scope();
504      let _ = crate::runtime::run_blocking(assert_no_unclosed_scopes(), ());
505    }
506  }
507}