Mocking Strategies
Mocking Is a Boundary Design Problem
If mocking feels hard in Rust, it is often a design signal: your code has unclear boundaries or too much logic mixed with I/O. Production-friendly testing starts with architecture: keep domain logic pure and isolate external dependencies behind narrow interfaces.
Production mindset:
- Mock only where the world is unreliable (network, DB, time)
- Keep core logic testable without mocks
- Prefer simple fakes over complex mocking frameworks
Rule 1: Make the Domain Pure
The easiest code to test is deterministic code. If your domain functions take plain inputs and return outputs without side effects, you do not need mocks.
pub fn price_with_tax(amount_cents: u64, tax_bp: u64) -> u64 {
amount_cents + (amount_cents * tax_bp / 10_000)
}
Unit test becomes trivial:
#[test]
fn computes_tax() {
assert_eq!(price_with_tax(1000, 250), 1025);
}
Production rule: push I/O to the edges, keep logic in the middle.
Rule 2: Use Traits for External Dependencies
For boundaries like DB or HTTP clients, define a small trait that captures what you need. Then provide a real implementation for production and a fake/mock for tests.
pub trait UserRepo {
fn get_email(&self, user_id: u64) -> Result<Option<String>, String>;
fn set_email(&self, user_id: u64, email: String) -> Result<(), String>;
}
Your service depends on the trait, not the concrete DB client.
pub struct UserService<R: UserRepo> {
repo: R,
}
impl<R: UserRepo> UserService<R> {
pub fn new(repo: R) -> Self {
Self { repo }
}
pub fn update_email(&self, user_id: u64, email: String) -> Result<(), String> {
if !email.contains("@") {
return Err("invalid email".to_string());
}
self.repo.set_email(user_id, email)
}
}
Prefer Fakes (In-Memory) Over Heavy Mocking
Instead of using a mocking framework, build a simple fake implementation that stores state in memory. This is often more stable and readable.
use std::collections::HashMap;
use std::cell::RefCell;
pub struct FakeUserRepo {
data: RefCell<HashMap<u64, String>>,
}
impl FakeUserRepo {
pub fn new() -> Self {
Self { data: RefCell::new(HashMap::new()) }
}
}
impl UserRepo for FakeUserRepo {
fn get_email(&self, user_id: u64) -> Result<Option<String>, String> {
Ok(self.data.borrow().get(&user_id).cloned())
}
fn set_email(&self, user_id: u64, email: String) -> Result<(), String> {
self.data.borrow_mut().insert(user_id, email);
Ok(())
}
}
Now you can test the service without real DB or network:
#[test]
fn updates_email() {
let repo = FakeUserRepo::new();
let svc = UserService::new(repo);
svc.update_email(1, "a@example.com".to_string()).unwrap();
}
Production note: this approach keeps tests fast and deterministic. For multi-threaded tests, use Mutex instead of RefCell.
Stubs vs Fakes vs Mocks
- Stub: returns fixed responses (no state)
- Fake: in-memory implementation with simple state
- Mock: verifies calls/expectations (interaction testing)
Production rule: prefer fakes when you need state and mocks only when interaction is the behavior you care about (rare).
When You Need Interaction Testing
Sometimes you need to verify you called a dependency correctly (e.g., you emit an event exactly once). A lightweight manual spy is often enough.
use std::cell::RefCell;
pub trait EventBus {
fn publish(&self, topic: &str, payload: &str) -> Result<(), String>;
}
pub struct SpyEventBus {
pub calls: RefCell<Vec<(String, String)>>,
}
impl SpyEventBus {
pub fn new() -> Self {
Self { calls: RefCell::new(vec![]) }
}
}
impl EventBus for SpyEventBus {
fn publish(&self, topic: &str, payload: &str) -> Result<(), String> {
self.calls.borrow_mut().push((topic.to_string(), payload.to_string()));
Ok(())
}
}
Test:
#[test]
fn publishes_event() {
let bus = SpyEventBus::new();
bus.publish("user.updated", "id=1").unwrap();
let calls = bus.calls.borrow();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0, "user.updated");
}
Avoid Mocking Time Directly
Time-based code becomes flaky if you depend on the real clock. Prefer injecting time or passing it in as an argument.
pub fn is_expired(now_ms: u64, expires_at_ms: u64) -> bool {
now_ms >= expires_at_ms
}
Then tests are deterministic without mocking time.
Production Pitfalls
- Mocking internal functions instead of boundaries
- Overusing mocking frameworks and writing fragile expectation-heavy tests
- Mixing I/O and domain logic, making everything require mocks
- Using global state in fakes causing test interference
Production Checklist
- Domain logic testable without mocks
- External dependencies hidden behind small traits
- Prefer fakes/in-memory implementations
- Use spies only when verifying interactions matters
- Keep tests deterministic and parallel-safe
Mocking in Rust is easiest when the codebase has clean boundaries. If you design for testability, you rarely need heavy mocking at all, and your production tests remain stable and high-signal.