ExamplesBy LevelBy TopicLearning Paths
752 Fundamental

752-test-doubles-taxonomy — Test Doubles Taxonomy

Functional Programming

Tutorial Video

Text description (accessibility)

This video demonstrates the "752-test-doubles-taxonomy — Test Doubles Taxonomy" functional Rust example. Difficulty level: Fundamental. Key concepts covered: Functional Programming. Gerard Meszaros coined the term "test doubles" in 2007 to categorize the different ways to replace real dependencies in tests. Key difference from OCaml: 1. **Drop

Tutorial

The Problem

Gerard Meszaros coined the term "test doubles" in 2007 to categorize the different ways to replace real dependencies in tests. Confusingly, many people call all test doubles "mocks." The taxonomy — Stub, Spy, Mock, Fake, Dummy — has precise meanings that guide which technique to use. Using the wrong double leads to tests that are either too coupled to implementation details (mocks everywhere) or too permissive (stubs that hide bugs). This example implements all five categories for a Logger dependency.

🎯 Learning Outcomes

  • • Implement a NullLogger stub that silently discards all log calls
  • • Build a SpyLogger that records all calls for later assertion
  • • Create a MockLogger that has pre-configured expectations and verifies them on drop
  • • Implement a FakeLogger that is a real working logger (writes to a Vec instead of a file)
  • • Know when to use each double: Dummy (don't care), Stub (canned return), Spy (verify calls), Mock (verify interactions), Fake (real implementation)
  • Code Example

    pub struct NullLogger;
    
    impl Logger for NullLogger {
        fn log(&self, _: &str) {}
        fn error(&self, _: &str) {}
    }

    Key Differences

  • Drop-based verification: Rust's MockLogger can verify expectations in Drop when the mock goes out of scope — OCaml has no automatic cleanup hook.
  • Interior mutability: Rust needs RefCell for mutable spy state accessed via &self; OCaml uses ref cells naturally.
  • Expectation DSL: Rust's mockall provides a rich .expect().times(2).returning(...) DSL; OCaml has no equivalent mature library.
  • Fake implementations: Both languages implement fakes as real implementations against a test backend (in-memory vs file); Rust's approach is structurally identical to OCaml's.
  • OCaml Approach

    OCaml uses module types and functors for the same purpose. A NULL_LOGGER module discards calls (stub). A spy implementation uses Queue.t ref to accumulate calls. OCaml's Alcotest checks are made after the function under test returns, inspecting the recorded calls. The ppx_mock package auto-generates mock implementations from module signatures.

    Full Source

    #![allow(clippy::all)]
    //! # Test Doubles Taxonomy
    //!
    //! Stub, Mock, Fake, Spy patterns in Rust.
    
    use std::cell::RefCell;
    
    /// The dependency trait
    pub trait Logger {
        fn log(&self, message: &str);
        fn error(&self, message: &str);
        fn warn(&self, message: &str);
    }
    
    // ═══════════════════════════════════════════════════════════════════════════════
    // 1. STUB: Returns canned values, ignores everything
    // ═══════════════════════════════════════════════════════════════════════════════
    
    /// A null logger that does nothing (simplest stub)
    pub struct NullLogger;
    
    impl Logger for NullLogger {
        fn log(&self, _: &str) {}
        fn error(&self, _: &str) {}
        fn warn(&self, _: &str) {}
    }
    
    // ═══════════════════════════════════════════════════════════════════════════════
    // 2. SPY: Records calls for later verification
    // ═══════════════════════════════════════════════════════════════════════════════
    
    /// A spy that records all calls
    pub struct SpyLogger {
        pub logs: RefCell<Vec<String>>,
        pub errors: RefCell<Vec<String>>,
        pub warns: RefCell<Vec<String>>,
    }
    
    impl SpyLogger {
        pub fn new() -> Self {
            SpyLogger {
                logs: RefCell::new(Vec::new()),
                errors: RefCell::new(Vec::new()),
                warns: RefCell::new(Vec::new()),
            }
        }
    
        pub fn log_count(&self) -> usize {
            self.logs.borrow().len()
        }
    
        pub fn error_count(&self) -> usize {
            self.errors.borrow().len()
        }
    }
    
    impl Default for SpyLogger {
        fn default() -> Self {
            Self::new()
        }
    }
    
    impl Logger for SpyLogger {
        fn log(&self, message: &str) {
            self.logs.borrow_mut().push(message.to_string());
        }
    
        fn error(&self, message: &str) {
            self.errors.borrow_mut().push(message.to_string());
        }
    
        fn warn(&self, message: &str) {
            self.warns.borrow_mut().push(message.to_string());
        }
    }
    
    // ═══════════════════════════════════════════════════════════════════════════════
    // 3. MOCK: Verifies expected interactions
    // ═══════════════════════════════════════════════════════════════════════════════
    
    /// A mock that verifies specific expectations
    pub struct MockLogger {
        expected_logs: RefCell<Vec<String>>,
        actual_logs: RefCell<Vec<String>>,
    }
    
    impl MockLogger {
        pub fn new() -> Self {
            MockLogger {
                expected_logs: RefCell::new(Vec::new()),
                actual_logs: RefCell::new(Vec::new()),
            }
        }
    
        pub fn expect_log(&self, message: &str) {
            self.expected_logs.borrow_mut().push(message.to_string());
        }
    
        pub fn verify(&self) -> bool {
            *self.expected_logs.borrow() == *self.actual_logs.borrow()
        }
    }
    
    impl Default for MockLogger {
        fn default() -> Self {
            Self::new()
        }
    }
    
    impl Logger for MockLogger {
        fn log(&self, message: &str) {
            self.actual_logs.borrow_mut().push(message.to_string());
        }
    
        fn error(&self, _: &str) {}
        fn warn(&self, _: &str) {}
    }
    
    // ═══════════════════════════════════════════════════════════════════════════════
    // 4. FAKE: Simplified working implementation
    // ═══════════════════════════════════════════════════════════════════════════════
    
    /// A fake logger that prints to stdout (simpler than file I/O)
    pub struct ConsoleLogger {
        prefix: String,
    }
    
    impl ConsoleLogger {
        pub fn new(prefix: &str) -> Self {
            ConsoleLogger {
                prefix: prefix.to_string(),
            }
        }
    }
    
    impl Logger for ConsoleLogger {
        fn log(&self, message: &str) {
            println!("[{}:INFO] {}", self.prefix, message);
        }
    
        fn error(&self, message: &str) {
            println!("[{}:ERROR] {}", self.prefix, message);
        }
    
        fn warn(&self, message: &str) {
            println!("[{}:WARN] {}", self.prefix, message);
        }
    }
    
    /// Service using the logger
    pub struct OrderProcessor<L: Logger> {
        logger: L,
    }
    
    impl<L: Logger> OrderProcessor<L> {
        pub fn new(logger: L) -> Self {
            OrderProcessor { logger }
        }
    
        pub fn process(&self, order_id: u64) -> Result<(), String> {
            self.logger.log(&format!("Processing order {}", order_id));
            if order_id == 0 {
                self.logger.error("Invalid order ID");
                return Err("Invalid order ID".to_string());
            }
            self.logger.log(&format!("Order {} completed", order_id));
            Ok(())
        }
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_with_null_logger() {
            let processor = OrderProcessor::new(NullLogger);
            assert!(processor.process(123).is_ok());
        }
    
        #[test]
        fn test_spy_records_calls() {
            let spy = SpyLogger::new();
            let processor = OrderProcessor::new(spy);
            processor.process(123).unwrap();
    
            assert_eq!(processor.logger.log_count(), 2);
            assert_eq!(processor.logger.error_count(), 0);
        }
    
        #[test]
        fn test_spy_records_errors() {
            let spy = SpyLogger::new();
            let processor = OrderProcessor::new(spy);
            let _ = processor.process(0);
    
            assert_eq!(processor.logger.error_count(), 1);
        }
    
        #[test]
        fn test_mock_verification() {
            let mock = MockLogger::new();
            mock.expect_log("Processing order 42");
            mock.expect_log("Order 42 completed");
    
            let processor = OrderProcessor::new(mock);
            processor.process(42).unwrap();
    
            assert!(processor.logger.verify());
        }
    
        #[test]
        fn test_mock_verification_fails() {
            let mock = MockLogger::new();
            mock.expect_log("wrong message");
    
            let processor = OrderProcessor::new(mock);
            processor.process(42).unwrap();
    
            assert!(!processor.logger.verify());
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_with_null_logger() {
            let processor = OrderProcessor::new(NullLogger);
            assert!(processor.process(123).is_ok());
        }
    
        #[test]
        fn test_spy_records_calls() {
            let spy = SpyLogger::new();
            let processor = OrderProcessor::new(spy);
            processor.process(123).unwrap();
    
            assert_eq!(processor.logger.log_count(), 2);
            assert_eq!(processor.logger.error_count(), 0);
        }
    
        #[test]
        fn test_spy_records_errors() {
            let spy = SpyLogger::new();
            let processor = OrderProcessor::new(spy);
            let _ = processor.process(0);
    
            assert_eq!(processor.logger.error_count(), 1);
        }
    
        #[test]
        fn test_mock_verification() {
            let mock = MockLogger::new();
            mock.expect_log("Processing order 42");
            mock.expect_log("Order 42 completed");
    
            let processor = OrderProcessor::new(mock);
            processor.process(42).unwrap();
    
            assert!(processor.logger.verify());
        }
    
        #[test]
        fn test_mock_verification_fails() {
            let mock = MockLogger::new();
            mock.expect_log("wrong message");
    
            let processor = OrderProcessor::new(mock);
            processor.process(42).unwrap();
    
            assert!(!processor.logger.verify());
        }
    }

    Deep Comparison

    OCaml vs Rust: Test Doubles Taxonomy

    The Four Test Doubles

    TypePurposeExample
    StubReturns canned valuesNullLogger
    SpyRecords calls for verificationSpyLogger
    MockVerifies expected interactionsMockLogger
    FakeSimplified working implConsoleLogger

    Stub (Simplest)

    Rust

    pub struct NullLogger;
    
    impl Logger for NullLogger {
        fn log(&self, _: &str) {}
        fn error(&self, _: &str) {}
    }
    

    OCaml

    module NullLogger : LOGGER = struct
      let log _ = ()
      let error _ = ()
    end
    

    Spy (Records Calls)

    Rust

    pub struct SpyLogger {
        pub logs: RefCell<Vec<String>>,
    }
    
    impl Logger for SpyLogger {
        fn log(&self, message: &str) {
            self.logs.borrow_mut().push(message.to_string());
        }
    }
    

    Mock (Verifies Expectations)

    Rust

    let mock = MockLogger::new();
    mock.expect_log("Processing order 42");
    mock.expect_log("Order 42 completed");
    
    process_order(42, &mock);
    
    assert!(mock.verify());
    

    Key Differences

    AspectOCamlRust
    Interior mutabilityrefRefCell
    Modules vs TraitsFirst-class modulesTrait objects/generics
    Mocking librariesLess commonmockall, mockito
    VerificationManualCan be automated

    When to Use Each

  • Stub: When you don't care about interactions
  • Spy: When you want to verify what was called
  • Mock: When you need strict interaction checking
  • Fake: When you need a simpler but working implementation
  • Exercises

  • Implement a ThrottledLogger fake that is a real logger but rate-limits to N messages per second, and write tests that verify the throttling behavior.
  • Extend MockLogger to support expect_log("message") that asserts a specific log message was recorded, and expect_no_errors() that asserts no error() calls occurred.
  • Build a CompositeLogger that forwards to multiple loggers simultaneously, and write a test using a SpyLogger + NullLogger to verify all messages reach both.
  • Open Source Repos