Skip to content
ExponaBLOG
RUST PROGRAMMING · DAY 038 / 100

Mocking in Rust: concepts, trade-offs and production lessons

Test without real dependencies.

Expona Admin7 min read

Introduction

When you're testing Rust services that depend on databases, message queues, payment gateways or external APIs, running against the real thing every time is slow, flaky and expensive. Mocking in Rust: concepts, trade-offs and production lessons gives us the patterns to isolate code under test and verify behavior without those dependencies running. After working through dozens of test suites across e-commerce and SaaS platforms, we've learned that the right mocking strategy depends on what you're testing and how much control you need over the mock's behavior.

Traits make dependencies swappable

The foundation of testable Rust is the trait. By designing your code around trait abstractions rather than concrete types, you create seams where tests can inject alternative implementations. This is the same dependency injection pattern teams use in C#, Java and TypeScript—Rust just makes you explicit about it.

Consider a payment processor that charges customers. In production, you want to call Stripe. In tests, you want something simpler:

rust
pub trait PaymentGateway {
    fn charge(&self, customer_id: &str, amount_cents: u64) -> Result<String, PaymentError>;
}

pub struct StripeGateway {
    api_key: String,
}

impl PaymentGateway for StripeGateway {
    fn charge(&self, customer_id: &str, amount_cents: u64) -> Result<String, PaymentError> {
        // Real HTTP call to Stripe
        todo!()
    }
}

pub struct OrderService {
    gateway: Box<dyn PaymentGateway>,
}

impl OrderService {
    pub fn new(gateway: Box<dyn PaymentGateway>) -> Self {
        Self { gateway }
    }

    pub fn place_order(&self, customer_id: &str, amount_cents: u64) -> Result<Order, OrderError> {
        let txn_id = self.gateway.charge(customer_id, amount_cents)?;
        Ok(Order { id: txn_id, amount_cents })
    }
}

Now tests can pass in a stub or mock implementation instead of the real gateway. The `OrderService` doesn't care which implementation it receives, as long as it satisfies the `PaymentGateway` trait. This pattern scales: use traits for database connections, HTTP clients, cache layers, message brokers and anything else your core logic depends on. The trait acts as a contract, and tests can provide simpler implementations that verify the right calls happen.

mockall for generated mocks

Writing manual mock implementations gets tedious fast. The mockall crate generates mocks automatically from trait definitions. It lets you set expectations, verify method calls, and control return values with minimal boilerplate.

Add mockall to your dev dependencies:

rust
[dev-dependencies]
mockall = "0.12"

Then mark your trait with #[automock]:

rust
use mockall::predicate::*;
use mockall::mock;

#[automock]
pub trait PaymentGateway {
    fn charge(&self, customer_id: &str, amount_cents: u64) -> Result<String, PaymentError>;
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_order_charges_customer() {
        let mut mock_gateway = MockPaymentGateway::new();
        
        // Expect charge to be called once with these exact arguments
        mock_gateway
            .expect_charge()
            .with(eq("cust_123"), eq(5000))
            .times(1)
            .returning(|_, _| Ok("txn_456".to_string()));

        let service = OrderService::new(Box::new(mock_gateway));
        let result = service.place_order("cust_123", 5000);

        assert!(result.is_ok());
    }

    #[test]
    fn test_order_fails_when_payment_fails() {
        let mut mock_gateway = MockPaymentGateway::new();
        
        mock_gateway
            .expect_charge()
            .returning(|_, _| Err(PaymentError::InsufficientFunds));

        let service = OrderService::new(Box::new(mock_gateway));
        let result = service.place_order("cust_123", 5000);

        assert!(result.is_err());
    }
}

mockall shines when you need precise control: verify that a method was called with specific arguments, set different return values for different calls, or check the sequence of calls. It's invaluable for testing error paths and edge cases where the real dependency might be hard to put into the right state. The downside is that mockall adds compile time and generates code that can be opaque when you're debugging a test failure.

Fakes are often simpler

A fake is a lightweight, real implementation that works differently from production but behaves correctly for your domain logic. Unlike mocks, fakes don't track expectations—they just provide data or behavior. For many scenarios, fakes are easier to maintain and understand than mocks.

Imagine a repository trait that queries orders:

rust
pub trait OrderRepository {
    fn find_by_id(&self, id: &str) -> Result<Order, RepositoryError>;
    fn save(&self, order: &Order) -> Result<(), RepositoryError>;
}

pub struct FakeOrderRepository {
    orders: RefCell<HashMap<String, Order>>,
}

impl FakeOrderRepository {
    pub fn new() -> Self {
        Self {
            orders: RefCell::new(HashMap::new()),
        }
    }
}

impl OrderRepository for FakeOrderRepository {
    fn find_by_id(&self, id: &str) -> Result<Order, RepositoryError> {
        self.orders
            .borrow()
            .get(id)
            .cloned()
            .ok_or(RepositoryError::NotFound)
    }

    fn save(&self, order: &Order) -> Result<(), RepositoryError> {
        self.orders.borrow_mut().insert(order.id.clone(), order.clone());
        Ok(())
    }
}

The fake holds orders in memory and implements the same interface as the real repository. Your test logic doesn't change—you still call the same methods and check results. The benefit: the fake is deterministic, fast, and easy to reason about. You can set up test data inline without writing mock expectations. Fakes work well for straightforward repositories, caches and registries. They don't work as well for complex interactions where you need to verify that specific calls happened in a specific order.

Testcontainers for real databases

Sometimes the best way to test is not to mock at all. When your logic depends heavily on SQL queries, transactions or database-specific behavior, running against a real Postgres or MySQL instance in a container is more reliable than any mock. The testcontainers crate makes this practical by spinning up Docker containers on demand for your test suite.

rust
[dev-dependencies]
testcontainers = "0.15"
tokio = { version = "1", features = ["full"] }

Here's how you'd set up a Postgres container for an integration test:

rust
use testcontainers::clients;
use testcontainers::images::postgres;

#[tokio::test]
async fn test_order_persistence() {
    let docker = clients::Cli::default();
    let image = postgres::Postgres::default();
    let container = docker.run(image);

    let connection_string = format!(
        "postgresql://postgres:postgres@127.0.0.1:{}/postgres",
        container.get_host_port_ipv4(5432)
    );

    let pool = sqlx::PgPoolOptions::new()
        .max_connections(1)
        .connect(&connection_string)
        .await
        .unwrap();

    sqlx::query(
        "CREATE TABLE orders (id TEXT PRIMARY KEY, customer_id TEXT, amount_cents INT)"
    )
    .execute(&pool)
    .await
    .unwrap();

    let repo = PostgresOrderRepository::new(pool);
    let order = Order {
        id: "ord_123".to_string(),
        customer_id: "cust_456".to_string(),
        amount_cents: 2999,
    };

    repo.save(&order).await.unwrap();
    let found = repo.find_by_id("ord_123").await.unwrap();

    assert_eq!(found.amount_cents, 2999);
}

Each test gets its own fresh database container with predictable initial state. You test against real SQL behavior, indexes, transactions and constraints. The trade-off: tests are slower because they wait for Docker to start and for schema setup. We typically reserve testcontainers for integration tests while using mocks or fakes for unit tests. In our experience, a CI pipeline with a modest test suite runs testcontainers tests in under a minute, which is acceptable for catching real bugs that mocks would miss.

Common mistakes

  • Over-mocking. Mocking the database, cache, and logger in a single test makes it hard to know what's actually being tested. Mock the external boundaries; test the logic with real or fake internal services.
  • Brittle mock expectations. Tests that fail when you refactor internal call patterns are a drag. Mock behavior and outcomes, not implementation details.
  • Forgetting to clean up test state. If your fake holds mutable state, reset it between tests or each test affects the next. Use RefCell or Mutex carefully; consider factory functions to create fresh instances.
  • Mixing unit and integration tests. Keep them separate with different test modules or crate features. Unit tests run fast; integration tests verify the whole system but are slower.
  • Using mocks when fakes are sufficient. Mocks add cognitive overhead. Reach for a simple fake first; only switch to mockall when you need to verify specific call sequences or error injection.

Key takeaways

  • Design around trait abstractions so tests can inject alternative implementations without rewriting core logic.
  • Use mockall to generate mocks from traits when you need precise control over method call expectations and return values.
  • Prefer fakes—simple, real implementations—for repositories, caches and registries where behavior is straightforward and mocking overhead isn't justified.
  • Run testcontainers for integration tests against real database instances to catch query bugs and transaction issues that mocks miss.
  • Keep unit tests fast with mocks and fakes; reserve testcontainers for tests that verify the full stack.
  • Mock external dependencies (APIs, payment gateways); test internal logic and domain rules with real or fake alternatives.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles