752-test-doubles-taxonomy — Test Doubles Taxonomy
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
NullLogger stub that silently discards all log callsSpyLogger that records all calls for later assertionMockLogger that has pre-configured expectations and verifies them on dropFakeLogger that is a real working logger (writes to a Vec instead of a file)Code Example
pub struct NullLogger;
impl Logger for NullLogger {
fn log(&self, _: &str) {}
fn error(&self, _: &str) {}
}Key Differences
MockLogger can verify expectations in Drop when the mock goes out of scope — OCaml has no automatic cleanup hook.RefCell for mutable spy state accessed via &self; OCaml uses ref cells naturally.mockall provides a rich .expect().times(2).returning(...) DSL; OCaml has no equivalent mature library.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());
}
}#[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
| Type | Purpose | Example |
|---|---|---|
| Stub | Returns canned values | NullLogger |
| Spy | Records calls for verification | SpyLogger |
| Mock | Verifies expected interactions | MockLogger |
| Fake | Simplified working impl | ConsoleLogger |
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
| Aspect | OCaml | Rust |
|---|---|---|
| Interior mutability | ref | RefCell |
| Modules vs Traits | First-class modules | Trait objects/generics |
| Mocking libraries | Less common | mockall, mockito |
| Verification | Manual | Can be automated |
When to Use Each
Exercises
ThrottledLogger fake that is a real logger but rate-limits to N messages per second, and write tests that verify the throttling behavior.MockLogger to support expect_log("message") that asserts a specific log message was recorded, and expect_no_errors() that asserts no error() calls occurred.CompositeLogger that forwards to multiple loggers simultaneously, and write a test using a SpyLogger + NullLogger to verify all messages reach both.