ExamplesBy LevelBy TopicLearning Paths
758 Fundamental

758-test-isolation-patterns — Test Isolation Patterns

Functional Programming

Tutorial Video

Text description (accessibility)

This video demonstrates the "758-test-isolation-patterns — Test Isolation Patterns" functional Rust example. Difficulty level: Fundamental. Key concepts covered: Functional Programming. Tests that share mutable global state are non-deterministic when run in parallel: test A's changes leak into test B's state, causing intermittent failures that are painful to debug. Key difference from OCaml: 1. **Default behavior**: Rust tests run in parallel by default (multiple threads); OCaml's `Alcotest` is sequential, making global state less hazardous.

Tutorial

The Problem

Tests that share mutable global state are non-deterministic when run in parallel: test A's changes leak into test B's state, causing intermittent failures that are painful to debug. The solution is test isolation: every test operates on its own independent state. Dependency injection, scoped state, and per-test instances replace global singletons. This is a fundamental principle of reliable test suites used in every professional codebase.

🎯 Learning Outcomes

  • • Identify global state as a source of test pollution and flakiness
  • • Use dependency injection to replace global state with per-test instances
  • • Implement a Counter trait with AtomicCounter for isolated per-test counting
  • • Use Arc<Mutex<T>> for shared-but-isolated test state
  • • Understand how Rust's test runner parallelism exacerbates global state problems
  • Code Example

    pub trait Counter {
        fn increment(&self) -> u64;
    }
    
    pub struct Service<C: Counter> {
        counter: C,
    }
    
    impl<C: Counter> Service<C> {
        pub fn new(counter: C) -> Self {
            Service { counter }
        }
    }

    Key Differences

  • Default behavior: Rust tests run in parallel by default (multiple threads); OCaml's Alcotest is sequential, making global state less hazardous.
  • Global statics: Rust's static variables with OnceLock create permanent global state; OCaml's module-level ref cells are equivalent.
  • Isolation mechanism: Rust uses per-test struct instances; OCaml uses local let bindings for isolated state.
  • Thread safety: Rust's type system (Send, Sync) prevents accidental sharing of non-thread-safe state; OCaml's runtime lock (before 5.0) serialized all threads.
  • OCaml Approach

    OCaml's immutable-by-default style naturally avoids most global state issues. Mutable state uses ref cells, which tests can scope locally. For shared mutable state across OCaml threads, Mutex.t wraps a ref. The Alcotest framework runs tests sequentially, reducing (but not eliminating) global state hazards. OCaml's effect system (5.0+) provides another mechanism for scoped state injection.

    Full Source

    #![allow(clippy::all)]
    //! # Test Isolation Patterns
    //!
    //! Ensuring tests don't interfere with each other.
    
    use std::cell::RefCell;
    use std::collections::HashMap;
    use std::sync::{Arc, Mutex, OnceLock};
    
    /// A global service (anti-pattern without isolation)
    static GLOBAL_COUNTER: OnceLock<Mutex<u64>> = OnceLock::new();
    
    fn global_counter() -> &'static Mutex<u64> {
        GLOBAL_COUNTER.get_or_init(|| Mutex::new(0))
    }
    
    /// Increment the global counter (test pollution risk!)
    pub fn increment_global() -> u64 {
        let mut guard = global_counter().lock().unwrap();
        *guard += 1;
        *guard
    }
    
    // ═══════════════════════════════════════════════════════════════════════════════
    // BETTER: Dependency Injection for Isolation
    // ═══════════════════════════════════════════════════════════════════════════════
    
    /// A counter service that can be injected
    pub trait Counter {
        fn increment(&self) -> u64;
        fn get(&self) -> u64;
        fn reset(&self);
    }
    
    /// Thread-safe counter implementation
    pub struct AtomicCounter {
        value: Mutex<u64>,
    }
    
    impl AtomicCounter {
        pub fn new() -> Self {
            AtomicCounter {
                value: Mutex::new(0),
            }
        }
    }
    
    impl Default for AtomicCounter {
        fn default() -> Self {
            Self::new()
        }
    }
    
    impl Counter for AtomicCounter {
        fn increment(&self) -> u64 {
            let mut guard = self.value.lock().unwrap();
            *guard += 1;
            *guard
        }
    
        fn get(&self) -> u64 {
            *self.value.lock().unwrap()
        }
    
        fn reset(&self) {
            *self.value.lock().unwrap() = 0;
        }
    }
    
    /// Service that uses an injected counter
    pub struct Service<C: Counter> {
        counter: C,
        name: String,
    }
    
    impl<C: Counter> Service<C> {
        pub fn new(name: &str, counter: C) -> Self {
            Service {
                counter,
                name: name.to_string(),
            }
        }
    
        pub fn process(&self) -> String {
            let count = self.counter.increment();
            format!("[{}] Processed item #{}", self.name, count)
        }
    
        pub fn status(&self) -> String {
            format!("[{}] Count: {}", self.name, self.counter.get())
        }
    }
    
    // ═══════════════════════════════════════════════════════════════════════════════
    // Test-specific implementations
    // ═══════════════════════════════════════════════════════════════════════════════
    
    /// A per-test isolated counter
    pub struct IsolatedCounter {
        value: RefCell<u64>,
    }
    
    impl IsolatedCounter {
        pub fn new() -> Self {
            IsolatedCounter {
                value: RefCell::new(0),
            }
        }
    }
    
    impl Default for IsolatedCounter {
        fn default() -> Self {
            Self::new()
        }
    }
    
    impl Counter for IsolatedCounter {
        fn increment(&self) -> u64 {
            let mut v = self.value.borrow_mut();
            *v += 1;
            *v
        }
    
        fn get(&self) -> u64 {
            *self.value.borrow()
        }
    
        fn reset(&self) {
            *self.value.borrow_mut() = 0;
        }
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_isolated_counter_1() {
            // Each test gets its own counter - no pollution
            let counter = IsolatedCounter::new();
            let service = Service::new("test1", counter);
            assert_eq!(service.process(), "[test1] Processed item #1");
            assert_eq!(service.process(), "[test1] Processed item #2");
        }
    
        #[test]
        fn test_isolated_counter_2() {
            // This test is independent of test_isolated_counter_1
            let counter = IsolatedCounter::new();
            let service = Service::new("test2", counter);
            assert_eq!(service.process(), "[test2] Processed item #1");
        }
    
        #[test]
        fn test_atomic_counter() {
            let counter = AtomicCounter::new();
            assert_eq!(counter.increment(), 1);
            assert_eq!(counter.increment(), 2);
            assert_eq!(counter.get(), 2);
            counter.reset();
            assert_eq!(counter.get(), 0);
        }
    
        #[test]
        fn test_service_status() {
            let counter = IsolatedCounter::new();
            let service = Service::new("status", counter);
            service.process();
            service.process();
            assert_eq!(service.status(), "[status] Count: 2");
        }
    
        #[test]
        fn test_shared_counter_with_arc() {
            let counter = Arc::new(AtomicCounter::new());
            let c1 = counter.clone();
            let c2 = counter.clone();
    
            c1.increment();
            c2.increment();
    
            assert_eq!(counter.get(), 2);
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_isolated_counter_1() {
            // Each test gets its own counter - no pollution
            let counter = IsolatedCounter::new();
            let service = Service::new("test1", counter);
            assert_eq!(service.process(), "[test1] Processed item #1");
            assert_eq!(service.process(), "[test1] Processed item #2");
        }
    
        #[test]
        fn test_isolated_counter_2() {
            // This test is independent of test_isolated_counter_1
            let counter = IsolatedCounter::new();
            let service = Service::new("test2", counter);
            assert_eq!(service.process(), "[test2] Processed item #1");
        }
    
        #[test]
        fn test_atomic_counter() {
            let counter = AtomicCounter::new();
            assert_eq!(counter.increment(), 1);
            assert_eq!(counter.increment(), 2);
            assert_eq!(counter.get(), 2);
            counter.reset();
            assert_eq!(counter.get(), 0);
        }
    
        #[test]
        fn test_service_status() {
            let counter = IsolatedCounter::new();
            let service = Service::new("status", counter);
            service.process();
            service.process();
            assert_eq!(service.status(), "[status] Count: 2");
        }
    
        #[test]
        fn test_shared_counter_with_arc() {
            let counter = Arc::new(AtomicCounter::new());
            let c1 = counter.clone();
            let c2 = counter.clone();
    
            c1.increment();
            c2.increment();
    
            assert_eq!(counter.get(), 2);
        }
    }

    Deep Comparison

    OCaml vs Rust: Test Isolation Patterns

    The Problem: Test Pollution

    Global mutable state causes tests to interfere with each other:

  • • Order-dependent failures
  • • Flaky tests
  • • Non-reproducible bugs
  • Solution: Dependency Injection

    Rust

    pub trait Counter {
        fn increment(&self) -> u64;
    }
    
    pub struct Service<C: Counter> {
        counter: C,
    }
    
    impl<C: Counter> Service<C> {
        pub fn new(counter: C) -> Self {
            Service { counter }
        }
    }
    

    OCaml

    module type COUNTER = sig
      val increment : unit -> int
    end
    
    module Service (C : COUNTER) = struct
      let process () = C.increment ()
    end
    

    Per-Test Isolation

    Rust

    #[test]
    fn test_1() {
        let counter = IsolatedCounter::new();  // Fresh state
        let service = Service::new(counter);
        assert_eq!(service.process(), 1);
    }
    
    #[test]
    fn test_2() {
        let counter = IsolatedCounter::new();  // Another fresh state
        let service = Service::new(counter);
        assert_eq!(service.process(), 1);  // Same result!
    }
    

    Key Differences

    AspectOCamlRust
    DI mechanismFirst-class modulesGenerics + traits
    Interior mutabilityrefRefCell, Mutex
    Global stateDiscouragedOnceLock, lazy_static
    Thread safetyNot automaticSync + Send bounds

    Exercises

  • Write a TestDatabase that wraps a HashMap and is created fresh per test, then refactor UserService to accept Box<dyn Database> for full isolation.
  • Implement a test_serial! macro that serializes specific tests using a process-wide Mutex for tests that genuinely cannot avoid shared resources (e.g., a real file path).
  • Build a Sandbox type that encapsulates a TempDir + AtomicCounter + MockEmailSender and provides a single entry point for all test dependencies in a service test.
  • Open Source Repos