758-test-isolation-patterns — Test Isolation Patterns
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
Counter trait with AtomicCounter for isolated per-test countingArc<Mutex<T>> for shared-but-isolated test stateCode 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
Alcotest is sequential, making global state less hazardous.static variables with OnceLock create permanent global state; OCaml's module-level ref cells are equivalent.let bindings for isolated state.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);
}
}#[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:
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
| Aspect | OCaml | Rust |
|---|---|---|
| DI mechanism | First-class modules | Generics + traits |
| Interior mutability | ref | RefCell, Mutex |
| Global state | Discouraged | OnceLock, lazy_static |
| Thread safety | Not automatic | Sync + Send bounds |
Exercises
TestDatabase that wraps a HashMap and is created fresh per test, then refactor UserService to accept Box<dyn Database> for full isolation.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).Sandbox type that encapsulates a TempDir + AtomicCounter + MockEmailSender and provides a single entry point for all test dependencies in a service test.