No Mock
In Shizuku, we advocate for a “no mock” approach to testing. This philosophy stems from the observation that tests with mocks often test implementation details rather than behavior, leading to brittle tests that provide false confidence.
Why Avoid Mocks?
Mock-based tests typically:
- Test implementation details instead of behavior
- Break when refactoring, even when behavior remains unchanged
- Create maintenance overhead
- Provide false confidence about system behavior
Best Practices
1. Pure Functions for Core Logic
Extract core algorithms into pure functions that:
- Take all dependencies as parameters
- Have no side effects
- Return consistent results for same inputs
- Are easily testable without mocks
// Bad: Logic mixed with I/Oasync fn process_order(db: &Database, order: Order) { let total = calculate_total(&order); db.save_order(order).await?; send_notification(total).await?;}
// Good: Pure function for core logicfn calculate_order_total(items: &[OrderItem]) -> Money { items.iter() .fold(Money::zero(), |acc, item| acc + item.price * item.quantity)}
2. Integration Tests for I/O
Test real I/O operations in integration tests:
- Use actual databases (like test containers)
- Connect to real NATS instances
- Test complete workflows
- Verify actual system behavior
#[tokio::test]async fn test_order_processing() { let nats = TestNatsServer::start().await; let db = TestDatabase::setup().await;
// Test real workflow let service = OrderService::new(nats.client(), db.connection()); let result = service.process(test_order()).await;
assert!(result.is_ok()); assert!(db.order_exists(test_order.id).await);}
3. Strategy Pattern with Pure Functions
Implement strategies as pure functions that can be tested independently:
// Strategy trait using pure functionstrait PricingStrategy: Send + Sync { fn calculate_price(&self, quantity: u32, base_price: Money) -> Money;}
// Easily testable implementationstruct BulkDiscountStrategy { threshold: u32, discount_percentage: f64,}
impl PricingStrategy for BulkDiscountStrategy { fn calculate_price(&self, quantity: u32, base_price: Money) -> Money { if quantity >= self.threshold { base_price * quantity * (1.0 - self.discount_percentage) } else { base_price * quantity } }}
4. Separating Logic from I/O Using Processors
The key idea is to separate your business logic from I/O operations. By using Processor<I, Result<O, Error>>
, we can:
-
Keep Logic Pure:
- Validation processors contain only business rules
- No database calls, HTTP requests, or file operations
- Pure functions that transform data
-
Chain Transformations:
- Each processor focuses on one transformation
- Results flow from one processor to the next
- Early returns on validation failures
-
Handle I/O at Edges:
- Keep I/O operations at the outer layer
- Core business logic remains pure
- Only test I/O in integration tests
For example, instead of:
async fn process_order(db: &DB, order: Order) -> Result<OrderId, Error> { let user = db.get_user(order.user_id).await?; // I/O let plan = db.get_plan(user.plan_id).await?; // I/O validate_order(&order, &plan)?; // Logic db.save_order(order).await // I/O}
Split into pure functions and processors:
// Good: Pure validation logic separate from Processorfn validate_order_against_plan(order: &Order, plan: &Plan) -> Result<ValidatedOrder, Error> { // Pure business rules if order.items.len() > plan.max_items { return Err(Error::LimitExceeded); } if order.total() > plan.max_amount { return Err(Error::AmountExceeded); } Ok(ValidatedOrder::new(order.clone()))}
// Database operations processorstruct DataReadProcessor { db: DatabaseConnection,}
impl Processor<Order, Result<(Order, Plan), Error>> for DataReadProcessor { async fn process(&self, order: Order) -> Result<(Order, Plan), Error> { let plan = self.db.get_plan(order.plan_id).await?; Ok((order, plan)) }}
impl Processor<Order, Result<OrderId, Error>> for DataReadProcessor { async fn process(&self, order: Order) -> Result<OrderId, Error> { // Get data let (order, plan): (Order, Plan) = self.process(order).await?;
// Pure validation let validated = validate_order_against_plan(&order, &plan)?;
// Save self.db.save_order(validated).await }}
This approach means:
- Business logic can be tested without mocks
- I/O operations are isolated and explicit
- Core rules are protected from external concerns
This approach:
- Makes validation logic pure and testable
- Provides type-safe error handling
- Creates composable validation chains
- Maintains clear data flow through type signatures
Benefits of No-Mock Approach
- Reliable Tests: Tests verify actual behavior, not implementation details
- Refactoring Confidence: Tests remain valid when implementation changes
- Better Design: Encourages separation of pure logic from I/O
- Simplified Testing: No need to maintain complex mock objects
- Real Coverage: Test coverage reflects actual system behavior
When to Use Test Doubles
While we avoid mocks, some test doubles are acceptable:
- Stubs for providing test data
- Fakes for simulating complex but pure behavior
- Test containers for real but isolated services
Remember: The goal is to test behavior, not implementation.