ExamplesBy LevelBy TopicLearning Paths
747 Fundamental

747-test-fixtures — Test Fixtures

Functional Programming

Tutorial Video

Text description (accessibility)

This video demonstrates the "747-test-fixtures — Test Fixtures" functional Rust example. Difficulty level: Fundamental. Key concepts covered: Functional Programming. Tests that rely on shared mutable state are fragile: one test's teardown failure corrupts the next test's starting state. Key difference from OCaml: 1. **Cleanup guarantees**: Rust's `Drop` runs even on panic, guaranteeing cleanup; OCaml requires explicit `try ... finally` or framework brackets.

Tutorial

The Problem

Tests that rely on shared mutable state are fragile: one test's teardown failure corrupts the next test's starting state. RAII-based test fixtures solve this: the fixture sets up state in a constructor and tears it down in Drop, guaranteeing cleanup even if the test panics. A DatabaseFixture that seeds test data and clears it on drop ensures every test starts from a known clean state, regardless of test order or failures.

🎯 Learning Outcomes

  • • Implement RAII teardown using Drop for automatic test cleanup
  • • Use OnceLock<Mutex<T>> to share expensive initialization across tests in a suite
  • • Build a FixtureBuilder that creates fixtures with customizable seed data
  • • Understand the tension between test isolation (each test gets fresh state) and performance (shared setup)
  • • Write tests that use the fixture's Drop guarantee to test cleanup behavior itself
  • Code Example

    struct DatabaseFixture {
        pub db: Database,
        name: &'static str,
    }
    
    impl DatabaseFixture {
        fn new(name: &'static str) -> Self {
            let db = Database::with_test_data();
            DatabaseFixture { db, name }
        }
    }
    
    impl Drop for DatabaseFixture {
        fn drop(&mut self) {
            // Teardown runs even if test panics!
        }
    }
    
    #[test]
    fn test_lookup_existing() {
        let f = DatabaseFixture::new("lookup");
        assert_eq!(f.db.get("user:1"), Some("Alice"));
    }

    Key Differences

  • Cleanup guarantees: Rust's Drop runs even on panic, guaranteeing cleanup; OCaml requires explicit try ... finally or framework brackets.
  • Shared initialization: Rust uses OnceLock for lazy singleton initialization; OCaml uses lazy values or Lazy.force for the same purpose.
  • Parallel tests: Rust tests run in parallel by default; shared Mutex<Database> must be used carefully to avoid test interference.
  • Setup ergonomics: Rust's builder pattern for fixtures is idiomatic; OCaml typically passes setup parameters as function arguments to avoid mutable state.
  • OCaml Approach

    OCaml's Alcotest framework provides bracket : (unit -> 'a) -> ('a -> unit) -> ('a -> unit) -> unit for setup/teardown pairs. OUnit2 uses similar bracket combinators. Since OCaml lacks RAII, teardown is never guaranteed on exception — tests must use try ... with or the framework's bracket to ensure cleanup. Jane Street's Async_kernel provides Deferred.bracket for asynchronous test fixtures.

    Full Source

    #![allow(clippy::all)]
    //! # Test Fixtures
    //!
    //! RAII teardown, shared state, and per-test isolation patterns.
    
    use std::collections::HashMap;
    use std::sync::{Mutex, OnceLock};
    
    /// A simple key-value database for testing
    #[derive(Debug)]
    pub struct Database {
        store: HashMap<String, String>,
    }
    
    impl Database {
        /// Create a new empty database
        pub fn new() -> Self {
            Database {
                store: HashMap::new(),
            }
        }
    
        /// Insert a key-value pair
        pub fn insert(&mut self, key: &str, value: &str) {
            self.store.insert(key.to_owned(), value.to_owned());
        }
    
        /// Get a value by key
        pub fn get(&self, key: &str) -> Option<&str> {
            self.store.get(key).map(String::as_str)
        }
    
        /// Delete a key, returns true if it existed
        pub fn delete(&mut self, key: &str) -> bool {
            self.store.remove(key).is_some()
        }
    
        /// Count of entries
        pub fn count(&self) -> usize {
            self.store.len()
        }
    }
    
    impl Default for Database {
        fn default() -> Self {
            Self::new()
        }
    }
    
    /// A fixture builder for testing
    pub struct DatabaseBuilder {
        db: Database,
    }
    
    impl DatabaseBuilder {
        /// Create a new builder
        pub fn new() -> Self {
            DatabaseBuilder {
                db: Database::new(),
            }
        }
    
        /// Add a user entry
        pub fn with_user(mut self, id: u32, name: &str) -> Self {
            self.db.insert(&format!("user:{}", id), name);
            self
        }
    
        /// Add arbitrary key-value
        pub fn with_entry(mut self, key: &str, value: &str) -> Self {
            self.db.insert(key, value);
            self
        }
    
        /// Build the database
        pub fn build(self) -> Database {
            self.db
        }
    }
    
    impl Default for DatabaseBuilder {
        fn default() -> Self {
            Self::new()
        }
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        // RAII fixture: auto-teardown via Drop
        struct DatabaseFixture {
            pub db: Database,
            #[allow(dead_code)]
            name: &'static str,
        }
    
        impl DatabaseFixture {
            fn new(name: &'static str) -> Self {
                let db = DatabaseBuilder::new()
                    .with_user(1, "Alice")
                    .with_user(2, "Bob")
                    .with_user(3, "Carol")
                    .build();
                DatabaseFixture { db, name }
            }
        }
    
        impl Drop for DatabaseFixture {
            fn drop(&mut self) {
                // Teardown runs even if test panics
            }
        }
    
        #[test]
        fn test_lookup_existing_user() {
            let f = DatabaseFixture::new("lookup_existing");
            assert_eq!(f.db.get("user:1"), Some("Alice"));
        }
    
        #[test]
        fn test_lookup_missing_returns_none() {
            let f = DatabaseFixture::new("lookup_missing");
            assert_eq!(f.db.get("user:99"), None);
        }
    
        #[test]
        fn test_insert_and_retrieve() {
            let mut f = DatabaseFixture::new("insert_retrieve");
            f.db.insert("user:4", "Dave");
            assert_eq!(f.db.get("user:4"), Some("Dave"));
        }
    
        #[test]
        fn test_delete_reduces_count() {
            let mut f = DatabaseFixture::new("delete");
            let before = f.db.count();
            assert!(f.db.delete("user:1"));
            assert_eq!(f.db.count(), before - 1);
        }
    
        #[test]
        fn test_delete_nonexistent_returns_false() {
            let mut f = DatabaseFixture::new("delete_nonexistent");
            assert!(!f.db.delete("ghost:999"));
        }
    
        // Shared read-only fixture via OnceLock
        static SHARED_DATA: OnceLock<Vec<i32>> = OnceLock::new();
    
        fn shared_data() -> &'static [i32] {
            SHARED_DATA.get_or_init(|| (1..=100).collect())
        }
    
        #[test]
        fn test_shared_data_sum() {
            let data = shared_data();
            let sum: i32 = data.iter().sum();
            assert_eq!(sum, 5050);
        }
    
        #[test]
        fn test_shared_data_length() {
            assert_eq!(shared_data().len(), 100);
        }
    
        // Mutex for shared mutable state (prefer isolation over this)
        static COUNTER: OnceLock<Mutex<u32>> = OnceLock::new();
    
        fn get_counter() -> &'static Mutex<u32> {
            COUNTER.get_or_init(|| Mutex::new(0))
        }
    
        #[test]
        fn test_counter_increment() {
            let mut guard = get_counter().lock().unwrap();
            let before = *guard;
            *guard += 1;
            assert_eq!(*guard, before + 1);
        }
    
        #[test]
        fn test_builder_pattern() {
            let db = DatabaseBuilder::new()
                .with_user(1, "Test")
                .with_entry("config:timeout", "30")
                .build();
            assert_eq!(db.get("user:1"), Some("Test"));
            assert_eq!(db.get("config:timeout"), Some("30"));
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        // RAII fixture: auto-teardown via Drop
        struct DatabaseFixture {
            pub db: Database,
            #[allow(dead_code)]
            name: &'static str,
        }
    
        impl DatabaseFixture {
            fn new(name: &'static str) -> Self {
                let db = DatabaseBuilder::new()
                    .with_user(1, "Alice")
                    .with_user(2, "Bob")
                    .with_user(3, "Carol")
                    .build();
                DatabaseFixture { db, name }
            }
        }
    
        impl Drop for DatabaseFixture {
            fn drop(&mut self) {
                // Teardown runs even if test panics
            }
        }
    
        #[test]
        fn test_lookup_existing_user() {
            let f = DatabaseFixture::new("lookup_existing");
            assert_eq!(f.db.get("user:1"), Some("Alice"));
        }
    
        #[test]
        fn test_lookup_missing_returns_none() {
            let f = DatabaseFixture::new("lookup_missing");
            assert_eq!(f.db.get("user:99"), None);
        }
    
        #[test]
        fn test_insert_and_retrieve() {
            let mut f = DatabaseFixture::new("insert_retrieve");
            f.db.insert("user:4", "Dave");
            assert_eq!(f.db.get("user:4"), Some("Dave"));
        }
    
        #[test]
        fn test_delete_reduces_count() {
            let mut f = DatabaseFixture::new("delete");
            let before = f.db.count();
            assert!(f.db.delete("user:1"));
            assert_eq!(f.db.count(), before - 1);
        }
    
        #[test]
        fn test_delete_nonexistent_returns_false() {
            let mut f = DatabaseFixture::new("delete_nonexistent");
            assert!(!f.db.delete("ghost:999"));
        }
    
        // Shared read-only fixture via OnceLock
        static SHARED_DATA: OnceLock<Vec<i32>> = OnceLock::new();
    
        fn shared_data() -> &'static [i32] {
            SHARED_DATA.get_or_init(|| (1..=100).collect())
        }
    
        #[test]
        fn test_shared_data_sum() {
            let data = shared_data();
            let sum: i32 = data.iter().sum();
            assert_eq!(sum, 5050);
        }
    
        #[test]
        fn test_shared_data_length() {
            assert_eq!(shared_data().len(), 100);
        }
    
        // Mutex for shared mutable state (prefer isolation over this)
        static COUNTER: OnceLock<Mutex<u32>> = OnceLock::new();
    
        fn get_counter() -> &'static Mutex<u32> {
            COUNTER.get_or_init(|| Mutex::new(0))
        }
    
        #[test]
        fn test_counter_increment() {
            let mut guard = get_counter().lock().unwrap();
            let before = *guard;
            *guard += 1;
            assert_eq!(*guard, before + 1);
        }
    
        #[test]
        fn test_builder_pattern() {
            let db = DatabaseBuilder::new()
                .with_user(1, "Test")
                .with_entry("config:timeout", "30")
                .build();
            assert_eq!(db.get("user:1"), Some("Test"));
            assert_eq!(db.get("config:timeout"), Some("30"));
        }
    }

    Deep Comparison

    OCaml vs Rust: Test Fixtures

    RAII Teardown Pattern

    OCaml

    let with_database name f =
      let db = setup_database name in
      Fun.protect ~finally:(fun () -> teardown_database db) (fun () -> f db)
    
    let%test "lookup existing" =
      with_database "test" (fun db ->
        Db.get db "user:1" = Some "Alice"
      )
    

    Rust

    struct DatabaseFixture {
        pub db: Database,
        name: &'static str,
    }
    
    impl DatabaseFixture {
        fn new(name: &'static str) -> Self {
            let db = Database::with_test_data();
            DatabaseFixture { db, name }
        }
    }
    
    impl Drop for DatabaseFixture {
        fn drop(&mut self) {
            // Teardown runs even if test panics!
        }
    }
    
    #[test]
    fn test_lookup_existing() {
        let f = DatabaseFixture::new("lookup");
        assert_eq!(f.db.get("user:1"), Some("Alice"));
    }
    

    Shared Read-Only State

    OCaml

    let shared_data = lazy (List.init 100 (fun i -> i + 1))
    
    let%test "sum is 5050" =
      let data = Lazy.force shared_data in
      List.fold_left (+) 0 data = 5050
    

    Rust

    static SHARED_DATA: OnceLock<Vec<i32>> = OnceLock::new();
    
    fn shared_data() -> &'static [i32] {
        SHARED_DATA.get_or_init(|| (1..=100).collect())
    }
    
    #[test]
    fn test_sum() {
        let sum: i32 = shared_data().iter().sum();
        assert_eq!(sum, 5050);
    }
    

    Builder Pattern for Fixtures

    Rust

    let db = DatabaseBuilder::new()
        .with_user(1, "Alice")
        .with_user(2, "Bob")
        .with_entry("config:timeout", "30")
        .build();
    

    Key Differences

    AspectOCamlRust
    TeardownFun.protect ~finally:Drop trait
    Panic safetyException-safe with protectDrop runs even on panic
    Lazy initlazy keywordOnceLock::get_or_init
    Thread safetyNot by defaultOnceLock is thread-safe
    Builder patternOptional args / record updateMethod chaining

    Exercises

  • Extend TestFixture with a checkpoint() method that saves the current database state and a restore_checkpoint() that rolls back to it, enabling partial-state tests.
  • Implement a ParallelFixture that uses Arc<Mutex<Database>> and creates per-test copies of the shared state at the start of each test, ensuring full isolation.
  • Write a test that verifies the Drop cleanup actually runs by checking the database count before and after a fixture goes out of scope in a nested block.
  • Open Source Repos