Rust Mocking: mockall and Mock Traits
Mocking in Rust is fundamentally different from dynamic languages. You can't monkey-patch methods or swap implementations at runtime. Rust's static typing and ownership rules make the patterns you'd...
Key Insights
- Rust’s ownership system makes traditional mocking approaches impossible, but trait-based design creates natural seams for test doubles that improve both testability and architecture.
- The
mockallcrate eliminates boilerplate by auto-generating mock implementations, but understanding manual mock patterns first helps you design better trait boundaries. - Mocking should be a last resort—prefer real implementations for simple dependencies and reserve mocks for external services, databases, and non-deterministic behavior.
Introduction to Mocking in Rust
Mocking in Rust is fundamentally different from dynamic languages. You can’t monkey-patch methods or swap implementations at runtime. Rust’s static typing and ownership rules make the patterns you’d use in Python or JavaScript impossible.
This isn’t a limitation—it’s a feature. Rust forces you to design for testability upfront through traits. If you want to mock something, you need a trait boundary. This constraint produces cleaner architectures with explicit dependency injection.
The challenge is that manual mock implementations are tedious. For a trait with ten methods, you need to implement all ten, even if your test only cares about one. This is where mockall becomes invaluable.
The Mock Trait Pattern
Before reaching for any library, understand the manual approach. Every mock in Rust follows the same pattern: define a trait for your dependency, then create alternative implementations for testing.
// Define the trait boundary
pub trait UserRepository {
fn find_by_id(&self, id: u64) -> Option<User>;
fn save(&self, user: &User) -> Result<(), RepositoryError>;
}
// Production implementation
pub struct PostgresUserRepository {
pool: PgPool,
}
impl UserRepository for PostgresUserRepository {
fn find_by_id(&self, id: u64) -> Option<User> {
// Actual database query
todo!()
}
fn save(&self, user: &User) -> Result<(), RepositoryError> {
// Actual database insert
todo!()
}
}
// Manual test double
#[cfg(test)]
pub struct FakeUserRepository {
users: std::collections::HashMap<u64, User>,
}
#[cfg(test)]
impl UserRepository for FakeUserRepository {
fn find_by_id(&self, id: u64) -> Option<User> {
self.users.get(&id).cloned()
}
fn save(&self, user: &User) -> Result<(), RepositoryError> {
// For tests, always succeed
Ok(())
}
}
This pattern works, but it scales poorly. Each new trait method requires updating every mock. You also can’t easily verify that methods were called with specific arguments or a certain number of times.
Getting Started with mockall
The mockall crate generates mock implementations automatically. Add it to your dev dependencies:
[dev-dependencies]
mockall = "0.12"
The #[automock] attribute generates a MockTraitName struct for any trait:
use mockall::automock;
#[automock]
pub trait EmailService {
fn send(&self, to: &str, subject: &str, body: &str) -> Result<(), EmailError>;
fn validate_address(&self, email: &str) -> bool;
}
#[cfg(test)]
mod tests {
use super::*;
use mockall::predicate::*;
#[test]
fn test_registration_sends_welcome_email() {
let mut mock = MockEmailService::new();
mock.expect_validate_address()
.returning(|_| true);
mock.expect_send()
.with(eq("user@example.com"), eq("Welcome!"), always())
.times(1)
.returning(|_, _, _| Ok(()));
let service = RegistrationService::new(Box::new(mock));
service.register("user@example.com", "password123").unwrap();
}
}
The generated MockEmailService provides expect_* methods for each trait method. These let you define behavior and assertions in one place.
Setting Expectations and Return Values
Expectations are the core of mockall. Each expect_* call creates a checkpoint that must be satisfied by the end of the test.
#[automock]
pub trait PaymentGateway {
fn charge(&self, amount: u64, card_token: &str) -> Result<TransactionId, PaymentError>;
fn refund(&self, transaction_id: &TransactionId) -> Result<(), PaymentError>;
}
#[test]
fn test_order_processing_charges_correct_amount() {
let mut gateway = MockPaymentGateway::new();
// Expect exactly one call with specific arguments
gateway.expect_charge()
.with(eq(5000), eq("tok_visa_4242"))
.times(1)
.returning(|_, _| Ok(TransactionId::new("txn_123")));
// This expectation will never be called - that's fine with times(0)
gateway.expect_refund()
.times(0);
let processor = OrderProcessor::new(Box::new(gateway));
let result = processor.process_order(order_with_total(5000), "tok_visa_4242");
assert!(result.is_ok());
// Mock automatically verifies expectations when dropped
}
The times() method accepts several patterns:
.times(1) // Exactly once
.times(2..=5) // Between 2 and 5 times
.times(..) // Any number of times (including zero)
For argument matching, mockall::predicate provides utilities:
use mockall::predicate::*;
// Exact equality
.with(eq(42))
// Function predicate
.with(function(|x: &str| x.starts_with("user_")))
// Always match
.with(always())
// String patterns
.with(str::starts_with("prefix"))
Advanced mockall Features
Real tests often need to verify call ordering or handle complex argument matching. mockall provides sequences for ordered expectations:
use mockall::Sequence;
#[test]
fn test_transaction_commits_after_operations() {
let mut mock = MockDatabase::new();
let mut seq = Sequence::new();
mock.expect_begin_transaction()
.times(1)
.in_sequence(&mut seq)
.returning(|| Ok(()));
mock.expect_insert()
.times(1)
.in_sequence(&mut seq)
.returning(|_| Ok(()));
mock.expect_commit()
.times(1)
.in_sequence(&mut seq)
.returning(|| Ok(()));
// Test will fail if methods called out of order
run_migration(Box::new(mock));
}
For complex argument validation, use withf():
mock.expect_save_user()
.withf(|user: &User| {
user.email.contains("@") &&
user.created_at > Utc::now() - Duration::seconds(5)
})
.returning(|_| Ok(()));
Generic traits require the #[automock] attribute with type specifications:
#[automock]
pub trait Cache<T: Clone + Send + 'static> {
fn get(&self, key: &str) -> Option<T>;
fn set(&self, key: &str, value: T, ttl: Duration);
}
#[test]
fn test_caching_layer() {
let mut cache = MockCache::<UserSession>::new();
cache.expect_get()
.with(eq("session_abc"))
.returning(|_| Some(UserSession::default()));
// Use the mock
}
Mocking Async Traits
Async traits require the async-trait crate for now (until async fn in traits stabilizes fully). mockall integrates seamlessly:
use async_trait::async_trait;
use mockall::automock;
#[automock]
#[async_trait]
pub trait HttpClient: Send + Sync {
async fn get(&self, url: &str) -> Result<Response, HttpError>;
async fn post(&self, url: &str, body: &[u8]) -> Result<Response, HttpError>;
}
#[tokio::test]
async fn test_api_client_retries_on_failure() {
let mut http = MockHttpClient::new();
// First call fails
http.expect_get()
.with(eq("https://api.example.com/users"))
.times(1)
.returning(|_| Err(HttpError::Timeout));
// Second call succeeds
http.expect_get()
.with(eq("https://api.example.com/users"))
.times(1)
.returning(|_| Ok(Response::new(200, b"{\"users\": []}")));
let client = ApiClient::with_retries(Box::new(http), 3);
let result = client.fetch_users().await;
assert!(result.is_ok());
}
Note the order matters: expectations are matched in the order they’re defined. The first matching expectation handles the call.
Best Practices and Limitations
Design thin trait boundaries. Don’t mock your entire application. Create traits at integration points: databases, HTTP clients, file systems, clocks. A trait with 20 methods is a sign you need to split responsibilities.
// Too broad - hard to mock, hard to maintain
pub trait ApplicationService {
fn create_user(&self, ...) -> ...;
fn send_email(&self, ...) -> ...;
fn process_payment(&self, ...) -> ...;
// 17 more methods
}
// Better - focused interfaces
pub trait UserRepository { ... }
pub trait EmailSender { ... }
pub trait PaymentProcessor { ... }
Prefer real implementations when practical. An in-memory HashMap is simpler than a mock for many repository tests. A real reqwest client hitting a local test server often catches more bugs than a mock. Reserve mocks for:
- External services you don’t control
- Non-deterministic behavior (time, randomness)
- Expensive operations (network calls, disk I/O)
- Failure scenarios that are hard to trigger naturally
Understand mockall’s limitations. You cannot mock concrete types—only traits. This is a Rust constraint, not a mockall limitation. If you need to mock a struct, extract a trait first.
// Can't mock this directly
impl ConcreteService {
pub fn do_thing(&self) { ... }
}
// Extract a trait, then mock that
pub trait Service {
fn do_thing(&self);
}
impl Service for ConcreteService {
fn do_thing(&self) { ... }
}
Keep mocks simple. If your mock setup is longer than the test logic, you’re testing the mock, not your code. Complex mock configurations are a smell—consider integration tests instead.
mockall transforms Rust testing from tedious to productive. But remember: the goal isn’t maximum mock coverage. It’s confidence that your code works. Use mocks strategically at boundaries, and let Rust’s type system do the heavy lifting everywhere else.