Isolating dependencies in tests
Mocking replaces real dependencies with controlled test doubles. This isolates the code under test, making tests faster, more reliable, and easier to write.
Real dependencies cause testing challenges:
use std::collections::HashMap;
use std::sync::Arc;
/// Trait for dependencies we want to mock
pub trait UserRepository: Send + Sync {
fn find_by_id(&self, id: u64) -> Option<User>;
fn find_by_email(&self, email: &str) -> Option<User>;
fn save(&mut self, user: &User) -> Result<(), RepositoryError>;
fn delete(&mut self, id: u64) -> Result<(), RepositoryError>;
}
pub trait EmailService: Send + Sync {
fn send_email(&self, to: &str, subject: &str, body: &str) -> Result<(), EmailError>;
}
pub trait TimeProvider: Send + Sync {
fn now(&self) -> u64; // Unix timestamp
}
#[derive(Debug, Clone, PartialEq)]
pub struct User {
pub id: u64,
pub email: String,
pub name: String,
pub created_at: u64,
}
#[derive(Debug, PartialEq)]
pub enum RepositoryError {
NotFound,
DuplicateEmail,
ConnectionError,
}
#[derive(Debug, PartialEq)]
pub enum EmailError {
InvalidAddress,
SendFailed,
}
/// Service that uses the dependencies
pub struct UserService<R: UserRepository, E: EmailService, T: TimeProvider> {
repo: R,
email: E,
time: T,
}
impl<R: UserRepository, E: EmailService, T: TimeProvider> UserService<R, E, T> {
pub fn new(repo: R, email: E, time: T) -> Self {
UserService { repo, email, time }
}
pub fn register_user(&mut self, email: &str, name: &str) -> Result<User, String> {
// Check if email already exists
if self.repo.find_by_email(email).is_some() {
return Err("Email already registered".to_string());
}
let user = User {
id: self.time.now(), // Use timestamp as ID (simplified)
email: email.to_string(),
name: name.to_string(),
created_at: self.time.now(),
};
self.repo.save(&user)
.map_err(|e| format!("Failed to save: {:?}", e))?;
// Send welcome email
self.email.send_email(
email,
"Welcome!",
&format!("Hello {}, welcome to our service!", name)
).map_err(|e| format!("Failed to send email: {:?}", e))?;
Ok(user)
}
pub fn get_user(&self, id: u64) -> Option<User> {
self.repo.find_by_id(id)
}
}
/// Manual mock implementation
pub struct MockUserRepository {
users: HashMap<u64, User>,
pub find_by_id_calls: std::cell::RefCell<Vec<u64>>,
pub save_calls: std::cell::RefCell<Vec<User>>,
pub should_fail_save: bool,
}
impl MockUserRepository {
pub fn new() -> Self {
MockUserRepository {
users: HashMap::new(),
find_by_id_calls: std::cell::RefCell::new(Vec::new()),
save_calls: std::cell::RefCell::new(Vec::new()),
should_fail_save: false,
}
}
pub fn with_user(mut self, user: User) -> Self {
self.users.insert(user.id, user);
self
}
pub fn fail_on_save(mut self) -> Self {
self.should_fail_save = true;
self
}
}
impl Default for MockUserRepository {
fn default() -> Self {
Self::new()
}
}
impl UserRepository for MockUserRepository {
fn find_by_id(&self, id: u64) -> Option<User> {
self.find_by_id_calls.borrow_mut().push(id);
self.users.get(&id).cloned()
}
fn find_by_email(&self, email: &str) -> Option<User> {
self.users.values().find(|u| u.email == email).cloned()
}
fn save(&mut self, user: &User) -> Result<(), RepositoryError> {
self.save_calls.borrow_mut().push(user.clone());
if self.should_fail_save {
return Err(RepositoryError::ConnectionError);
}
self.users.insert(user.id, user.clone());
Ok(())
}
fn delete(&mut self, id: u64) -> Result<(), RepositoryError> {
self.users.remove(&id)
.map(|_| ())
.ok_or(RepositoryError::NotFound)
}
}
/// Mock email service that records calls
pub struct MockEmailService {
pub sent_emails: std::cell::RefCell<Vec<(String, String, String)>>,
pub should_fail: bool,
}
impl MockEmailService {
pub fn new() -> Self {
MockEmailService {
sent_emails: std::cell::RefCell::new(Vec::new()),
should_fail: false,
}
}
pub fn fail_on_send(mut self) -> Self {
self.should_fail = true;
self
}
}
impl Default for MockEmailService {
fn default() -> Self {
Self::new()
}
}
impl EmailService for MockEmailService {
fn send_email(&self, to: &str, subject: &str, body: &str) -> Result<(), EmailError> {
if self.should_fail {
return Err(EmailError::SendFailed);
}
self.sent_emails.borrow_mut().push((
to.to_string(),
subject.to_string(),
body.to_string(),
));
Ok(())
}
}
/// Mock time provider for deterministic testing
pub struct MockTimeProvider {
pub current_time: u64,
}
impl MockTimeProvider {
pub fn new(time: u64) -> Self {
MockTimeProvider { current_time: time }
}
}
impl TimeProvider for MockTimeProvider {
fn now(&self) -> u64 {
self.current_time
}
}
/// Real implementations (for production)
pub struct PostgresUserRepository {
// connection pool, etc.
}
pub struct SmtpEmailService {
// SMTP client, etc.
}
pub struct SystemTimeProvider;
impl TimeProvider for SystemTimeProvider {
fn now(&self) -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_service() -> (
UserService<MockUserRepository, MockEmailService, MockTimeProvider>,
MockUserRepository,
MockEmailService,
) {
let repo = MockUserRepository::new();
let email = MockEmailService::new();
let time = MockTimeProvider::new(1000);
// Note: We need to clone/share state or restructure for verification
// This is simplified for demonstration
(UserService::new(
MockUserRepository::new(),
MockEmailService::new(),
MockTimeProvider::new(1000),
), repo, email)
}
#[test]
fn test_register_user_success() {
let repo = MockUserRepository::new();
let email = MockEmailService::new();
let time = MockTimeProvider::new(1000);
let mut service = UserService::new(repo, email, time);
let result = service.register_user("test@example.com", "Test User");
assert!(result.is_ok());
let user = result.unwrap();
assert_eq!(user.email, "test@example.com");
assert_eq!(user.name, "Test User");
assert_eq!(user.created_at, 1000);
}
#[test]
fn test_register_duplicate_email() {
let existing_user = User {
id: 1,
email: "taken@example.com".to_string(),
name: "Existing".to_string(),
created_at: 500,
};
let repo = MockUserRepository::new().with_user(existing_user);
let email = MockEmailService::new();
let time = MockTimeProvider::new(1000);
let mut service = UserService::new(repo, email, time);
let result = service.register_user("taken@example.com", "New User");
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "Email already registered");
}
#[test]
fn test_register_handles_save_failure() {
let repo = MockUserRepository::new().fail_on_save();
let email = MockEmailService::new();
let time = MockTimeProvider::new(1000);
let mut service = UserService::new(repo, email, time);
let result = service.register_user("test@example.com", "Test");
assert!(result.is_err());
assert!(result.unwrap_err().contains("Failed to save"));
}
#[test]
fn test_register_handles_email_failure() {
let repo = MockUserRepository::new();
let email = MockEmailService::new().fail_on_send();
let time = MockTimeProvider::new(1000);
let mut service = UserService::new(repo, email, time);
let result = service.register_user("test@example.com", "Test");
assert!(result.is_err());
assert!(result.unwrap_err().contains("Failed to send email"));
}
#[test]
fn test_get_user() {
let user = User {
id: 42,
email: "alice@example.com".to_string(),
name: "Alice".to_string(),
created_at: 1000,
};
let repo = MockUserRepository::new().with_user(user.clone());
let email = MockEmailService::new();
let time = MockTimeProvider::new(1000);
let service = UserService::new(repo, email, time);
let found = service.get_user(42);
assert_eq!(found, Some(user));
let not_found = service.get_user(999);
assert_eq!(not_found, None);
}
}
// Example using mockall crate (when available)
#[cfg(feature = "mockall")]
mod mockall_example {
use mockall::{automock, predicate::*};
#[automock]
pub trait Database {
fn get(&self, key: &str) -> Option<String>;
fn set(&mut self, key: &str, value: &str) -> Result<(), String>;
}
#[test]
fn test_with_mockall() {
let mut mock = MockDatabase::new();
mock.expect_get()
.with(eq("key1"))
.times(1)
.returning(|_| Some("value1".to_string()));
mock.expect_set()
.with(eq("key2"), eq("value2"))
.times(1)
.returning(|_, _| Ok(()));
assert_eq!(mock.get("key1"), Some("value1".to_string()));
assert!(mock.set("key2", "value2").is_ok());
}
}
fn main() {
println!("Run tests with: cargo test");
println!("With mockall: cargo test --features mockall");
}
| Type | Purpose | Example |
|------|---------|---------|
| Dummy | Placeholder, not used | MockLogger that does nothing |
| Stub | Returns preset values | MockTime returning fixed timestamp |
| Spy | Records calls for verification | MockEmail storing sent messages |
| Mock | Verifies expectations | mockall's expect_* methods |
| Fake | Simplified working implementation | In-memory database |
// DON'T: Mock everything
fn test_add() {
let mock_adder = MockAdder::new();
mock_adder.expect_add().returning(|a, b| a + b);
// Why mock pure functions?
}
// DON'T: Test the mock, not the code
#[test]
fn test_mock_works() {
let mock = MockRepo::new();
mock.save(&user);
assert!(mock.was_called()); // This tests the mock!
}
// DO: Test behavior through the mock
#[test]
fn test_service_saves_user() {
let mut service = create_service_with_mocks();
service.register_user("email", "name").unwrap();
// Verify the service behaved correctly
}
Run this code in the official Rust Playground