๐Ÿฆ€ Functional Rust

752: Stubs, Mocks, Fakes, Spies Taxonomy

Difficulty: 2 Level: Intermediate The four kinds of test doubles โ€” Stub, Fake, Mock, Spy โ€” and when to use each in Rust using traits.

The Problem This Solves

When testing code that depends on external systems (databases, loggers, HTTP clients, clocks), you replace those dependencies with test doubles โ€” objects that stand in for the real thing during testing. But "mock" has been overloaded to mean everything; the precise taxonomy (Stub, Fake, Mock, Spy) helps you pick the right tool for each testing goal. In Rust, all test doubles are implemented by defining a trait for the dependency, then writing multiple implementations: one for production, several for tests. The trait boundary is the key โ€” it's what makes your code testable without changing its logic. This pattern is ubiquitous in enterprise Rust: web handlers that accept `&dyn Database`, CLIs that accept `&dyn Clock`, event processors that accept `&dyn Queue`. Dependency injection via traits is how you make code testable without reaching for DI frameworks.

The Intuition

The four test doubles serve different purposes:

How It Works in Rust

pub trait Logger {
 fn log(&self, message: &str);
 fn error(&self, message: &str);
 fn warn(&self, message: &str);
}

// 1. Stub: no-op implementation
pub struct NullLogger;
impl Logger for NullLogger {
 fn log(&self, _: &str) {}
 fn error(&self, _: &str) {}
 fn warn(&self, _: &str) {}
}

// 2. Fake: real logic, simplified storage
pub struct InMemoryLogger { logs: RefCell<Vec<String>>, errors: RefCell<Vec<String>> }
impl Logger for InMemoryLogger {
 fn log(&self, msg: &str) { self.logs.borrow_mut().push(msg.to_owned()); }
 // ...
}

// 3. Mock: records calls for assertion
pub struct MockLogger { calls: RefCell<Vec<LogCall>> }
impl MockLogger {
 pub fn assert_called_with(&self, level: &str, msg: &str) { /* ... */ }
 pub fn assert_call_count(&self, expected: usize) { /* ... */ }
}

// 4. Spy: delegates to inner + counts calls
pub struct SpyLogger<Inner: Logger> { inner: Inner, call_count: RefCell<usize> }
impl<I: Logger> Logger for SpyLogger<I> {
 fn log(&self, msg: &str) {
     *self.call_count.borrow_mut() += 1;
     self.inner.log(msg);  // real behavior preserved
 }
}

// Business logic accepts &dyn Logger โ€” works with any double
pub fn process_items(items: &[i32], logger: &dyn Logger) -> (usize, usize) { ... }
`RefCell` enables interior mutability: the `Logger` trait takes `&self`, but the fake/mock/spy need to mutate their recorded state. `RefCell` provides runtime-checked `&mut` access through a shared reference โ€” safe in single-threaded test code.

What This Unlocks

Key Differences

ConceptOCamlRust
Test double mechanismModule substitution or first-class functionsTrait implementations โ€” compile-time checked
Interior mutabilityMutable references always explicit`RefCell<T>` โ€” runtime-checked `borrow_mut()`
Trait objectsFirst-class modules, functors`&dyn Trait` (dynamic) or `impl Trait` (static)
Call recordingImperative mutation in closures`RefCell<Vec<LogCall>>` โ€” standard pattern
/// 752: Test Doubles Taxonomy โ€” Stub, Mock, Fake, Spy 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 nothing, ignores everything โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

pub struct NullLogger;

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

// โ”€โ”€ 2. Fake: working but simplified (in-memory) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

pub struct InMemoryLogger {
    logs:   RefCell<Vec<String>>,
    errors: RefCell<Vec<String>>,
    warns:  RefCell<Vec<String>>,
}

impl InMemoryLogger {
    pub fn new() -> Self {
        InMemoryLogger {
            logs:   RefCell::new(Vec::new()),
            errors: RefCell::new(Vec::new()),
            warns:  RefCell::new(Vec::new()),
        }
    }
    pub fn logs(&self)   -> Vec<String> { self.logs.borrow().clone() }
    pub fn errors(&self) -> Vec<String> { self.errors.borrow().clone() }
    pub fn warns(&self)  -> Vec<String> { self.warns.borrow().clone() }
    pub fn all_count(&self) -> usize {
        self.logs.borrow().len() + self.errors.borrow().len() + self.warns.borrow().len()
    }
}

impl Logger for InMemoryLogger {
    fn log(&self, msg: &str)   { self.logs.borrow_mut().push(msg.to_owned()); }
    fn error(&self, msg: &str) { self.errors.borrow_mut().push(msg.to_owned()); }
    fn warn(&self, msg: &str)  { self.warns.borrow_mut().push(msg.to_owned()); }
}

// โ”€โ”€ 3. Mock: records calls, asserts on them โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

#[derive(Debug, Clone)]
pub struct LogCall {
    pub level:   &'static str,
    pub message: String,
}

pub struct MockLogger {
    calls: RefCell<Vec<LogCall>>,
}

impl MockLogger {
    pub fn new() -> Self { MockLogger { calls: RefCell::new(Vec::new()) } }
    pub fn call_count(&self) -> usize { self.calls.borrow().len() }
    pub fn calls(&self) -> Vec<LogCall> { self.calls.borrow().clone() }
    pub fn assert_called_with(&self, level: &str, msg: &str) {
        let calls = self.calls.borrow();
        assert!(
            calls.iter().any(|c| c.level == level && c.message.contains(msg)),
            "Expected a {} call containing '{}', got: {:?}",
            level, msg, calls
        );
    }
    pub fn assert_call_count(&self, expected: usize) {
        assert_eq!(self.call_count(), expected,
            "Expected {} calls, got {}", expected, self.call_count());
    }
}

impl Logger for MockLogger {
    fn log(&self, msg: &str) {
        self.calls.borrow_mut().push(LogCall { level: "log", message: msg.to_owned() });
    }
    fn error(&self, msg: &str) {
        self.calls.borrow_mut().push(LogCall { level: "error", message: msg.to_owned() });
    }
    fn warn(&self, msg: &str) {
        self.calls.borrow_mut().push(LogCall { level: "warn", message: msg.to_owned() });
    }
}

// โ”€โ”€ 4. Spy: wraps real impl, also records calls โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

pub struct SpyLogger<Inner: Logger> {
    inner:      Inner,
    call_count: RefCell<usize>,
}

impl<I: Logger> SpyLogger<I> {
    pub fn new(inner: I) -> Self {
        SpyLogger { inner, call_count: RefCell::new(0) }
    }
    pub fn call_count(&self) -> usize { *self.call_count.borrow() }
}

impl<I: Logger> Logger for SpyLogger<I> {
    fn log(&self, msg: &str) {
        *self.call_count.borrow_mut() += 1;
        self.inner.log(msg);        // also calls the real implementation
    }
    fn error(&self, msg: &str) {
        *self.call_count.borrow_mut() += 1;
        self.inner.error(msg);
    }
    fn warn(&self, msg: &str) {
        *self.call_count.borrow_mut() += 1;
        self.inner.warn(msg);
    }
}

// โ”€โ”€ Business logic โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

pub fn process_items(items: &[i32], logger: &dyn Logger) -> (usize, usize) {
    let mut ok = 0usize;
    let mut errs = 0usize;
    for &item in items {
        if item < 0 {
            logger.error(&format!("negative item: {}", item));
            errs += 1;
        } else if item == 0 {
            logger.warn("zero item encountered");
            ok += 1;
        } else {
            logger.log(&format!("processing: {}", item));
            ok += 1;
        }
    }
    (ok, errs)
}

fn main() {
    let data = &[1i32, -2, 0, 3, -4];
    let logger = InMemoryLogger::new();
    let (ok, errs) = process_items(data, &logger);
    println!("ok={} errs={}", ok, errs);
    println!("Logs:   {:?}", logger.logs());
    println!("Errors: {:?}", logger.errors());
    println!("Warns:  {:?}", logger.warns());
}

#[cfg(test)]
mod tests {
    use super::*;

    // โ”€โ”€ Stub test: we don't care about logging behaviour โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

    #[test]
    fn stub_does_not_panic() {
        let stub = NullLogger;
        process_items(&[1, 2, 3], &stub);   // just verify it runs
    }

    // โ”€โ”€ Fake tests: verify observable state โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

    #[test]
    fn fake_records_errors_for_negative_items() {
        let fake = InMemoryLogger::new();
        process_items(&[1, -2, -3], &fake);
        assert_eq!(fake.errors().len(), 2);
        assert!(fake.errors()[0].contains("-2"));
    }

    #[test]
    fn fake_records_warns_for_zero() {
        let fake = InMemoryLogger::new();
        process_items(&[0, 0], &fake);
        assert_eq!(fake.warns().len(), 2);
    }

    // โ”€โ”€ Mock tests: verify interactions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

    #[test]
    fn mock_assert_called_with_error_for_negative() {
        let mock = MockLogger::new();
        process_items(&[-42], &mock);
        mock.assert_called_with("error", "-42");
    }

    #[test]
    fn mock_assert_call_count() {
        let mock = MockLogger::new();
        process_items(&[1, 2, -3, 0, -5], &mock);
        mock.assert_call_count(5);  // one call per item
    }

    // โ”€โ”€ Spy tests: real impl + call recording โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

    #[test]
    fn spy_wraps_fake_and_counts() {
        let inner = InMemoryLogger::new();
        let spy   = SpyLogger::new(inner);
        process_items(&[1, -2, 0], &spy);
        assert_eq!(spy.call_count(), 3);
        // Inner fake also received the calls:
        assert_eq!(spy.inner.errors().len(), 1);
    }
}
(* 752: Test Doubles Taxonomy โ€” OCaml *)

(* The interface/dependency *)
module type LOGGER = sig
  val log   : string -> unit
  val error : string -> unit
end

(* โ”€โ”€ Stub: returns canned values, no verification โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ *)
module NullLogger : LOGGER = struct
  let log   _ = ()
  let error _ = ()
end

(* โ”€โ”€ Fake: working but simplified (in-memory) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ *)
module InMemoryLogger : sig
  include LOGGER
  val get_logs   : unit -> string list
  val get_errors : unit -> string list
end = struct
  let logs   = ref []
  let errors = ref []
  let log   s = logs   := s :: !logs
  let error s = errors := s :: !errors
  let get_logs   () = List.rev !logs
  let get_errors () = List.rev !errors
end

(* โ”€โ”€ Mock: records interactions for assertion โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ *)
module MockLogger : sig
  include LOGGER
  val call_count : unit -> int
  val reset : unit -> unit
end = struct
  let calls = ref 0
  let log   _ = incr calls
  let error _ = incr calls
  let call_count () = !calls
  let reset () = calls := 0
end

(* Business logic *)
module App(L : LOGGER) = struct
  let process items =
    List.iter (fun item ->
      if item < 0
      then L.error (Printf.sprintf "negative item: %d" item)
      else L.log (Printf.sprintf "processing: %d" item)
    ) items
end

let () =
  (* Using Stub: silent, no verification *)
  let module A = App(NullLogger) in
  A.process [1; -2; 3];
  Printf.printf "Stub: processed silently\n";

  (* Using Fake: readable log *)
  let module B = App(InMemoryLogger) in
  B.process [1; -2; 3];
  Printf.printf "Fake logs: [%s]\n"
    (String.concat "; " (InMemoryLogger.get_logs ()));
  Printf.printf "Fake errors: [%s]\n"
    (String.concat "; " (InMemoryLogger.get_errors ()));

  (* Using Mock: count calls *)
  MockLogger.reset ();
  let module C = App(MockLogger) in
  C.process [1; 2; 3; -1; -2];
  Printf.printf "Mock: %d call(s) made\n" (MockLogger.call_count ())