Skip to main content
⚡ Calmops

Rust Testing & Mocking Deep Dive

Master unit tests, integration tests, mocking strategies, and property-based testing

Testing is not an afterthought in Rust—it’s woven into the language’s DNA. The Rust compiler and toolchain provide first-class support for writing, organizing, and running tests. In this article, we’ll explore testing comprehensively: from basic unit tests to advanced mocking strategies and property-based testing. You’ll learn why Rust’s approach to testing is powerful and how to use it effectively in real-world projects.


Why Testing Matters in Rust

Rust’s compiler catches many bugs at compile time through its type system and borrow checker. However, it cannot catch logic errors, incorrect business logic, or integration issues. This is where testing becomes essential.

Rust’s testing philosophy emphasizes:

  • Tests are first-class citizens - The language has built-in support for tests
  • Tests live alongside code - Tests stay in the same file as the code they test
  • Easy to run - A single command runs all tests
  • Type safety extends to tests - The same safety guarantees apply to test code

Unit Testing: The Foundation

Basic Unit Test Structure

In Rust, you write unit tests in the same file as your code, typically in a #[cfg(test)] module.

// filepath: src/calculator.rs
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

pub fn subtract(a: i32, b: i32) -> i32 {
    a - b
}

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

    #[test]
    fn test_add_positive_numbers() {
        assert_eq!(add(2, 3), 5);
    }

    #[test]
    fn test_subtract_positive_numbers() {
        assert_eq!(subtract(5, 3), 2);
    }

    #[test]
    fn test_add_negative_numbers() {
        assert_eq!(add(-2, -3), -5);
    }
}

The #[cfg(test)] attribute tells the compiler to only include this module when running tests. This keeps test code out of production binaries.

Running Tests

# Run all tests
cargo test

# Run tests in a specific module
cargo test calculator

# Run tests matching a specific name
cargo test test_add

# Run tests sequentially (by default, Rust runs tests in parallel)
cargo test -- --test-threads=1

# Run a single test and show output (even if it passes)
cargo test -- --nocapture test_add_positive_numbers

Assert Macros

Rust provides several assertion macros for different scenarios:

// filepath: src/assertion_examples.rs

#[cfg(test)]
mod tests {
    #[test]
    fn assert_equal_example() {
        let result = 2 + 2;
        assert_eq!(result, 4);      // Panics if not equal
    }

    #[test]
    fn assert_not_equal_example() {
        let result = 2 + 2;
        assert_ne!(result, 5);      // Panics if equal
    }

    #[test]
    fn assert_true_example() {
        let condition = true;
        assert!(condition);         // Panics if false
    }

    #[test]
    #[should_panic]
    fn test_panic_expected() {
        panic!("This is expected");
    }

    #[test]
    fn test_with_custom_message() {
        let x = 2;
        assert_eq!(x, 3, "Expected {} to equal 3, but got different value", x);
    }

    #[test]
    #[should_panic(expected = "index out of bounds")]
    fn test_specific_panic() {
        let v = vec![1, 2, 3];
        let _ = &v[10];  // This will panic with "index out of bounds"
    }
}

Testing With Result Types

Modern Rust tests can also return Result, which is cleaner than using #[should_panic]:

// filepath: src/result_tests.rs
#[cfg(test)]
mod tests {
    #[test]
    fn returns_result_example() -> Result<(), String> {
        let result = 2 + 2;
        if result == 4 {
            Ok(())
        } else {
            Err(String::from("Math is broken!"))
        }
    }

    #[test]
    fn with_question_mark() -> Result<(), String> {
        let value = parse_number("42")?;
        assert_eq!(value, 42);
        Ok(())
    }

    fn parse_number(s: &str) -> Result<i32, String> {
        s.parse().map_err(|_| "Invalid number".to_string())
    }
}

Integration Testing

Integration tests verify that multiple components work together correctly. They live in a separate directory and test the public API of your crate.

Project Structure

my_project/
├── src/
│   ├── lib.rs
│   └── calculator.rs
├── tests/           <-- Integration tests directory
│   └── integration_tests.rs
└── Cargo.toml

Writing Integration Tests

// filepath: tests/integration_tests.rs
use my_project::calculator::{add, subtract};

#[test]
fn integration_test_calculation_sequence() {
    let result = add(10, 5);
    let final_result = subtract(result, 3);
    assert_eq!(final_result, 12);
}

#[test]
fn integration_test_complex_workflow() {
    let x = add(5, 5);
    let y = add(x, 10);
    let z = subtract(y, 5);
    assert_eq!(z, 20);
}

Key differences from unit tests:

  • Integration tests are in a separate tests/ directory
  • They test the public API of your crate
  • Each file in tests/ is compiled as a separate binary
  • They don’t have access to private module internals

Shared Test Utilities

When you have multiple integration test files, you can share common setup code:

// filepath: tests/common/mod.rs
pub fn setup_test_database() -> TestDB {
    TestDB::new()
}

pub struct TestDB {
    data: Vec<String>,
}

impl TestDB {
    pub fn new() -> Self {
        TestDB { data: Vec::new() }
    }
}
// filepath: tests/test_database.rs
mod common;

use common::setup_test_database;

#[test]
fn test_with_shared_setup() {
    let db = setup_test_database();
    // Use the test database
}

Mocking Strategies

Mocking is crucial for testing code that depends on external services, file systems, or complex dependencies. Let’s explore several mocking approaches.

The idiomatic Rust approach to mocking is using traits. Design your code to depend on abstractions (traits), making it easy to swap real implementations with mocks.

// filepath: src/user_service.rs
use std::collections::HashMap;

// Define the trait abstraction
pub trait UserRepository {
    fn get_user(&self, id: u32) -> Option<User>;
    fn save_user(&mut self, user: User) -> Result<(), String>;
}

#[derive(Clone, Debug, PartialEq)]
pub struct User {
    pub id: u32,
    pub name: String,
    pub email: String,
}

// Real implementation
pub struct DatabaseUserRepository {
    // In real code, this would be a database connection
    users: HashMap<u32, User>,
}

impl DatabaseUserRepository {
    pub fn new() -> Self {
        DatabaseUserRepository {
            users: HashMap::new(),
        }
    }
}

impl UserRepository for DatabaseUserRepository {
    fn get_user(&self, id: u32) -> Option<User> {
        self.users.get(&id).cloned()
    }

    fn save_user(&mut self, user: User) -> Result<(), String> {
        self.users.insert(user.id, user);
        Ok(())
    }
}

// Service that depends on the trait, not the concrete implementation
pub struct UserService<R: UserRepository> {
    repository: R,
}

impl<R: UserRepository> UserService<R> {
    pub fn new(repository: R) -> Self {
        UserService { repository }
    }

    pub fn get_user_email(&self, id: u32) -> Option<String> {
        self.repository
            .get_user(id)
            .map(|user| user.email)
    }

    pub fn update_user_name(&mut self, id: u32, new_name: String) -> Result<(), String> {
        if let Some(mut user) = self.repository.get_user(id) {
            user.name = new_name;
            self.repository.save_user(user)?;
            Ok(())
        } else {
            Err("User not found".to_string())
        }
    }
}

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

    // Mock implementation
    struct MockUserRepository {
        users: HashMap<u32, User>,
    }

    impl MockUserRepository {
        fn new() -> Self {
            MockUserRepository {
                users: HashMap::new(),
            }
        }

        fn with_user(mut self, user: User) -> Self {
            self.users.insert(user.id, user);
            self
        }
    }

    impl UserRepository for MockUserRepository {
        fn get_user(&self, id: u32) -> Option<User> {
            self.users.get(&id).cloned()
        }

        fn save_user(&mut self, user: User) -> Result<(), String> {
            self.users.insert(user.id, user);
            Ok(())
        }
    }

    #[test]
    fn test_get_user_email() {
        let user = User {
            id: 1,
            name: "Alice".to_string(),
            email: "[email protected]".to_string(),
        };

        let mock_repo = MockUserRepository::new().with_user(user);
        let service = UserService::new(mock_repo);

        assert_eq!(
            service.get_user_email(1),
            Some("[email protected]".to_string())
        );
    }

    #[test]
    fn test_get_nonexistent_user() {
        let mock_repo = MockUserRepository::new();
        let service = UserService::new(mock_repo);

        assert_eq!(service.get_user_email(999), None);
    }

    #[test]
    fn test_update_user_name() {
        let user = User {
            id: 1,
            name: "Alice".to_string(),
            email: "[email protected]".to_string(),
        };

        let mock_repo = MockUserRepository::new().with_user(user);
        let mut service = UserService::new(mock_repo);

        let result = service.update_user_name(1, "Bob".to_string());
        assert!(result.is_ok());

        // Note: In real tests, we'd verify the repository was updated
        // This example is simplified
    }
}

Strategy 2: Using the mockito Crate

For mocking HTTP requests, use mockito:

// Cargo.toml
// [dev-dependencies]
// mockito = "1.2"

// filepath: src/http_client.rs
use std::error::Error;

pub struct ApiClient;

impl ApiClient {
    pub async fn fetch_user_data(user_id: u32) -> Result<String, Box<dyn Error>> {
        let url = format!("https://api.example.com/users/{}", user_id);
        let response = reqwest::get(&url).await?;
        Ok(response.text().await?)
    }
}

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

    #[tokio::test]
    async fn test_fetch_user_data() {
        let _m = mock("GET", mockito::Matcher::Regex(r"^https://api\.example\.com/users/1$".into()))
            .with_status(200)
            .with_body(r#"{"id": 1, "name": "Alice"}"#)
            .create();

        // In a real test, you would configure the client to use mockito's server
        // This example is simplified for illustration
    }
}

Strategy 3: Using proptest for Property-Based Testing

Property-based testing generates random inputs and verifies that properties hold true:

// Cargo.toml
// [dev-dependencies]
// proptest = "1.4"

// filepath: src/sorting.rs
pub fn bubble_sort(arr: &mut [i32]) {
    let n = arr.len();
    for i in 0..n {
        for j in 0..n - i - 1 {
            if arr[j] > arr[j + 1] {
                arr.swap(j, j + 1);
            }
        }
    }
}

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

    #[test]
    fn test_bubble_sort_example() {
        let mut arr = vec![3, 1, 4, 1, 5, 9, 2, 6];
        bubble_sort(&mut arr);
        assert_eq!(arr, vec![1, 1, 2, 3, 4, 5, 6, 9]);
    }

    proptest! {
        #[test]
        fn prop_sorted_array_is_sorted(mut vec in prop::collection::vec(-1000i32..1000, 0..100)) {
            bubble_sort(&mut vec);
            
            // Property: After sorting, each element is <= the next element
            for i in 0..vec.len() - 1 {
                assert!(vec[i] <= vec[i + 1]);
            }
        }

        #[test]
        fn prop_sorted_array_has_same_length(mut vec in prop::collection::vec(-1000i32..1000, 0..100)) {
            let original_len = vec.len();
            bubble_sort(&mut vec);
            
            // Property: Sorting doesn't change the length
            assert_eq!(vec.len(), original_len);
        }

        #[test]
        fn prop_sorted_array_has_same_elements(mut vec in prop::collection::vec(-1000i32..1000, 0..100)) {
            let mut expected = vec.clone();
            expected.sort();
            
            bubble_sort(&mut vec);
            
            // Property: All elements are preserved (multiset equality)
            let mut sorted_vec = vec.clone();
            sorted_vec.sort();
            assert_eq!(sorted_vec, expected);
        }
    }
}

Test Fixtures and Setup

For complex tests, you often need to set up shared state.

// filepath: src/bank_account.rs
pub struct BankAccount {
    balance: f64,
}

impl BankAccount {
    pub fn new(initial_balance: f64) -> Result<Self, String> {
        if initial_balance < 0.0 {
            Err("Initial balance cannot be negative".to_string())
        } else {
            Ok(BankAccount {
                balance: initial_balance,
            })
        }
    }

    pub fn deposit(&mut self, amount: f64) -> Result<(), String> {
        if amount <= 0.0 {
            Err("Deposit amount must be positive".to_string())
        } else {
            self.balance += amount;
            Ok(())
        }
    }

    pub fn withdraw(&mut self, amount: f64) -> Result<(), String> {
        if amount <= 0.0 {
            Err("Withdrawal amount must be positive".to_string())
        } else if amount > self.balance {
            Err("Insufficient funds".to_string())
        } else {
            self.balance -= amount;
            Ok(())
        }
    }

    pub fn balance(&self) -> f64 {
        self.balance
    }
}

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

    // Fixture: A helper function to create a test account
    fn create_test_account() -> BankAccount {
        BankAccount::new(1000.0).expect("Failed to create test account")
    }

    #[test]
    fn test_deposit() {
        let mut account = create_test_account();
        assert!(account.deposit(500.0).is_ok());
        assert_eq!(account.balance(), 1500.0);
    }

    #[test]
    fn test_withdraw_success() {
        let mut account = create_test_account();
        assert!(account.withdraw(300.0).is_ok());
        assert_eq!(account.balance(), 700.0);
    }

    #[test]
    fn test_withdraw_insufficient_funds() {
        let mut account = create_test_account();
        assert!(account.withdraw(2000.0).is_err());
        assert_eq!(account.balance(), 1000.0);  // Balance unchanged
    }

    #[test]
    fn test_negative_initial_balance() {
        assert!(BankAccount::new(-100.0).is_err());
    }
}

Common Pitfalls and Best Practices

Pitfall 1: Not Testing Edge Cases

// ❌ Bad: Only tests the happy path
#[test]
fn test_divide() {
    assert_eq!(divide(10, 2), 5);
}

// ✅ Good: Tests edge cases
#[test]
fn test_divide_happy_path() {
    assert_eq!(divide(10, 2), 5);
}

#[test]
#[should_panic(expected = "Division by zero")]
fn test_divide_by_zero() {
    divide(10, 0);
}

#[test]
fn test_divide_with_remainder() {
    assert_eq!(divide(10, 3), 3);  // Integer division
}

Pitfall 2: Tests That Are Too Interdependent

// ❌ Bad: Tests depend on execution order
#[cfg(test)]
mod tests {
    use std::sync::Mutex;

    lazy_static::lazy_static! {
        static ref COUNTER: Mutex<i32> = Mutex::new(0);
    }

    #[test]
    fn test_first() {
        let mut c = COUNTER.lock().unwrap();
        *c = 0;
    }

    #[test]
    fn test_second() {
        // Depends on test_first running first!
        let c = COUNTER.lock().unwrap();
        assert_eq!(*c, 0);
    }
}

// ✅ Good: Each test is independent
#[cfg(test)]
mod tests {
    use std::sync::Mutex;

    fn setup() -> Mutex<i32> {
        Mutex::new(0)
    }

    #[test]
    fn test_counter_independent_1() {
        let counter = setup();
        let mut c = counter.lock().unwrap();
        *c = 1;
        assert_eq!(*c, 1);
    }

    #[test]
    fn test_counter_independent_2() {
        let counter = setup();
        let mut c = counter.lock().unwrap();
        *c = 2;
        assert_eq!(*c, 2);
    }
}

Pitfall 3: Testing Implementation Details

// ❌ Bad: Tests implementation details
#[test]
fn test_internal_vector_size() {
    let list = LinkedList::new();
    // LinkedList's internal representation shouldn't be tested!
    assert_eq!(list.nodes.len(), 0);
}

// ✅ Good: Tests public behavior
#[test]
fn test_list_is_empty() {
    let list: LinkedList<i32> = LinkedList::new();
    assert!(list.is_empty());
}

Best Practice 1: Use Meaningful Test Names

// ❌ Unclear
#[test]
fn test_1() {
    assert!(process_payment(100.0));
}

// ✅ Clear
#[test]
fn test_process_payment_with_valid_amount_succeeds() {
    assert!(process_payment(100.0));
}

#[test]
fn test_process_payment_with_zero_amount_fails() {
    assert!(!process_payment(0.0));
}

Best Practice 2: One Assertion Per Test (When Possible)

// ❌ Multiple unrelated assertions
#[test]
fn test_user_operations() {
    let user = create_user("Alice", "[email protected]");
    assert_eq!(user.name, "Alice");
    assert_eq!(user.email, "[email protected]");
    assert!(user.is_active);
    assert_ne!(user.id, 0);
}

// ✅ Focused tests
#[test]
fn test_user_has_correct_name() {
    let user = create_user("Alice", "[email protected]");
    assert_eq!(user.name, "Alice");
}

#[test]
fn test_user_has_correct_email() {
    let user = create_user("Alice", "[email protected]");
    assert_eq!(user.email, "[email protected]");
}

#[test]
fn test_user_is_active_by_default() {
    let user = create_user("Alice", "[email protected]");
    assert!(user.is_active);
}

Best Practice 3: Use should_panic Carefully

// Better: Use Result-based tests
#[test]
fn test_invalid_parse() -> Result<(), String> {
    let result = "not_a_number".parse::<i32>();
    assert!(result.is_err());
    Ok(())
}

// Or test the specific behavior
#[test]
fn test_panic_on_invalid_parse() {
    let result = std::panic::catch_unwind(|| {
        "not_a_number".parse::<i32>().expect("Expected number")
    });
    assert!(result.is_err());
}

Advanced: Integration with CI/CD

# .github/workflows/test.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: dtolnay/rust-toolchain@stable
      
      - name: Run tests
        run: cargo test --verbose
      
      - name: Run tests with all features
        run: cargo test --all-features --verbose
      
      - name: Run doc tests
        run: cargo test --doc
      
      - name: Check code coverage
        uses: codecov/codecov-action@v3

Testing Strategies Summary

Strategy Use Case Pros Cons
Unit Tests Test individual functions Fast, isolated Limited scope
Integration Tests Test components together Tests real behavior Slower, harder to debug
Property-Based Tests Verify invariants Catches edge cases Requires property thinking
Trait Mocking Replace dependencies Idiomatic Rust Manual implementation
Mock Crates Mock HTTP/external services Complete Adds dependencies

Pros and Cons of Different Approaches

Trait-Based Mocking

Pros:

  • Idiomatic Rust
  • Zero runtime overhead
  • Type-safe
  • Works without additional crates

Cons:

  • Requires upfront design
  • More verbose for simple cases
  • Must create mock implementations manually

Mock Crates (mockito, mockall)

Pros:

  • Less boilerplate for complex scenarios
  • Can mock external HTTP services
  • Powerful for advanced use cases

Cons:

  • Adds dependencies
  • Runtime overhead
  • Less Rustic approach
  • Steeper learning curve

Property-Based Testing vs. Deterministic Tests

Pros (Property-Based):

  • Finds edge cases you wouldn’t think of
  • Provides regression testing
  • Excellent for algorithms

Cons (Property-Based):

  • Slower than deterministic tests
  • Requires thinking in properties
  • Can be flaky if not designed carefully

Further Resources and Alternative Technologies

Books and Articles

Testing Crates

Complementary Technologies

  • Code Coverage: tarpaulin, grcov
  • Fuzzing: cargo-fuzz for finding bugs with random inputs
  • Benchmarking: criterion for performance regression testing
  • Test Parallelization: Built-in, but use --test-threads=1 to serialize

CI/CD Platforms with Rust Support


Conclusion

Rust’s testing capabilities are powerful and idiomatic. By combining unit tests, integration tests, trait-based mocking, and property-based testing, you can build a comprehensive test suite that catches bugs early and gives you confidence in your code.

The key principles are:

  1. Write tests alongside code - Use #[cfg(test)] modules
  2. Design for testability - Use traits to abstract dependencies
  3. Test behavior, not implementation - Focus on what your code does, not how it does it
  4. Use the right tool for the job - Unit tests for logic, integration tests for workflows, property tests for invariants
  5. Keep tests independent - Each test should be runnable in isolation

With these practices in place, you’ll write more reliable, maintainable Rust code.



Happy testing! 🧪🦀

Comments