4 min read
On this page

Mocking & Property Testing

Unit tests with known inputs and expected outputs catch specific bugs. But some classes of bugs hide in the space between your test cases. Property testing generates thousands of random inputs to find edge cases you never imagined. Mocking isolates your code from external dependencies so you can test logic without databases, networks, or file systems.

Trait-Based Mocking with mockall

mockall generates mock implementations of traits. Define your dependency as a trait, then mock it in tests:

use std::collections::HashMap;

pub trait UserRepository {
    fn find_by_id(&self, id: u64) -> Option<User>;
    fn save(&mut self, user: &User) -> Result<(), String>;
}

#[derive(Debug, Clone, PartialEq)]
pub struct User {
    pub id: u64,
    pub name: String,
    pub active: bool,
}

pub struct UserService<R: UserRepository> {
    repo: R,
}

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

    pub fn deactivate(&mut self, user_id: u64) -> Result<(), String> {
        let mut user = self
            .repo
            .find_by_id(user_id)
            .ok_or_else(|| format!("user {} not found", user_id))?;
        user.active = false;
        self.repo.save(&user)
    }
}

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

    mock! {
        UserRepo {}
        impl UserRepository for UserRepo {
            fn find_by_id(&self, id: u64) -> Option<User>;
            fn save(&mut self, user: &User) -> Result<(), String>;
        }
    }

    #[test]
    fn test_deactivate_existing_user() {
        let mut mock_repo = MockUserRepo::new();

        mock_repo
            .expect_find_by_id()
            .with(eq(42))
            .returning(|_| {
                Some(User {
                    id: 42,
                    name: "Alice".into(),
                    active: true,
                })
            });

        mock_repo
            .expect_save()
            .withf(|user| user.id == 42 && !user.active)
            .returning(|_| Ok(()));

        let mut service = UserService::new(mock_repo);
        let result = service.deactivate(42);
        assert!(result.is_ok());
    }

    #[test]
    fn test_deactivate_missing_user() {
        let mut mock_repo = MockUserRepo::new();

        mock_repo
            .expect_find_by_id()
            .with(eq(999))
            .returning(|_| None);

        let mut service = UserService::new(mock_repo);
        let result = service.deactivate(999);
        assert_eq!(result.unwrap_err(), "user 999 not found");
    }
}

The mock! macro generates MockUserRepo with expect_* methods for setting up expectations. withf takes a predicate closure for custom matching.

When to Mock vs Use Real Implementations

Mocking is a tool, not a default. Use it deliberately:

Mock when:

  • The dependency is slow (database, network, file system)
  • The dependency is non-deterministic (time, random numbers)
  • You need to simulate error conditions that are hard to trigger naturally
  • The dependency has not been built yet

Use real implementations when:

  • The dependency is fast and deterministic (in-memory data structures)
  • You are testing integration between components
  • The mock would be more complex than the real implementation
  • You want confidence that components work together

A common approach: write an InMemoryUserRepo that implements UserRepository backed by a HashMap. Use it for most tests — less setup than mocks and more confidence that the interactions are realistic. Reserve mocks for simulating error conditions that are hard to trigger with real implementations.

Property-Based Testing with proptest

Property testing inverts the testing model: instead of specifying inputs and expected outputs, you specify properties that should hold for all inputs. The framework generates random inputs and tries to find violations:

use proptest::prelude::*;

pub fn reverse(s: &str) -> String {
    s.chars().rev().collect()
}

proptest! {
    #[test]
    fn test_reverse_twice_is_identity(s in ".*") {
        assert_eq!(reverse(&reverse(&s)), s);
    }

    #[test]
    fn test_reverse_preserves_length(s in "\\PC*") {
        assert_eq!(reverse(&s).len(), s.len());
    }
}
$ cargo test
running 2 tests
test test_reverse_twice_is_identity ... ok
test test_reverse_preserves_length ... ok

Each test runs with many random inputs (256 by default). If a failure is found, proptest shrinks the input to the minimal failing case.

Custom Strategies

For structured data, build custom strategies with prop_map:

use proptest::prelude::*;

fn order_strategy() -> impl Strategy<Value = (u32, u64)> {
    (1..1000u32, 1..100_000u64)
}

proptest! {
    #[test]
    fn test_total_scales(
        price in 1..1000u64,
        q1 in 1..100u32,
        q2 in 1..100u32,
    ) {
        let total1 = q1 as u64 * price;
        let total2 = q2 as u64 * price;
        if q1 > q2 {
            assert!(total1 > total2);
        }
    }
}

Shrinking

When proptest finds a failing input, it automatically shrinks it to the minimal reproduction. If a sort function has a bug that only manifests with lists longer than 10 elements, proptest will find a failing case and then reduce it to the smallest list that still fails. Shrunk cases are saved in proptest-regressions files so CI reproduces them consistently.

Fuzzing with cargo-fuzz

Fuzzing is property testing taken to the extreme: the fuzzer generates millions of inputs guided by code coverage, trying to crash your program:

// fuzz/fuzz_targets/parse_input.rs
#![no_main]
use libfuzzer_sys::fuzz_target;
use my_crate::parse;

fuzz_target!(|data: &[u8]| {
    if let Ok(s) = std::str::from_utf8(data) {
        let _ = parse(s);  // should never panic
    }
});
cargo +nightly fuzz run parse_input

Fuzzing is most valuable for parsers, deserializers, and any code that handles untrusted input. It finds panics, buffer overflows (in unsafe code), and logic errors that no human would think to test.

Test Coverage with cargo-tarpaulin

cargo-tarpaulin measures which lines your tests execute:

cargo install cargo-tarpaulin
cargo tarpaulin --out html
|| Tested/Total Lines:
|| src/lib.rs: 45/52 (86.54%)
|| src/parser.rs: 38/41 (92.68%)
|| src/validator.rs: 22/30 (73.33%)
||
|| 86.18% coverage, 105/122 lines covered

Coverage numbers are a signal, not a target. 100% line coverage does not mean bug-free code — it means every line executed, not that every logic path was verified. Use coverage to find untested code paths, not as a quality metric.

Combining Approaches

A robust test strategy uses multiple layers:

// Unit test: specific case, fast feedback
#[test]
fn test_parse_simple() {
    assert_eq!(parse("42"), Ok(42));
}

// Property test: verify invariants across random inputs
proptest! {
    #[test]
    fn test_parse_roundtrip(n in -1000..1000i32) {
        let s = n.to_string();
        assert_eq!(parse(&s), Ok(n));
    }
}

// Fuzz test: find crashes in untrusted input handling
// (in fuzz/fuzz_targets/parse_fuzz.rs)

Unit tests give you fast, specific feedback. Property tests find edge cases. Fuzzing finds crashes. Mocks isolate dependencies. Use all of them where they fit.

Common Pitfalls

  • Over-mocking — when every dependency is mocked, you are testing mock wiring, not behavior. If a bug lives in the interaction between components, mocks will not catch it.
  • Testing mock expectations instead of outcomes — verify the result of the operation, not just that certain methods were called. "The save method was called" is weaker than "the user is now deactivated."
  • Ignoring proptest failures — when proptest finds a failing case, it saves it in a proptest-regressions file. Do not delete this file; commit it so CI reproduces the failure.
  • Weak propertiestest_output_is_not_empty is barely better than no test. Find properties that actually constrain the behavior: round-trip properties, ordering invariants, conservation laws.
  • Coverage tunnel vision — chasing 100% coverage leads to tests that execute code without verifying behavior. A test that calls a function and ignores the result adds coverage but no confidence.
  • Brittle mock setups — if changing an implementation detail breaks 20 mock-based tests, the mocks are too tightly coupled. Mock at the boundary, not at every layer.

Key Takeaways

  • mockall generates mock implementations from traits. Use mocks for slow, non-deterministic, or unavailable dependencies.
  • Prefer real implementations (in-memory repos, test doubles) over mocks when feasible.
  • Property testing with proptest generates random inputs and finds edge cases you would not think to test manually.
  • Fuzzing with cargo-fuzz is essential for any code that handles untrusted input.
  • Use cargo-tarpaulin to find untested code paths, but do not treat coverage as a quality metric.
  • Layer your testing: unit tests for specific cases, property tests for invariants, fuzz tests for robustness, mocks for isolation.