Unit Testing

Test modules, assertions, and organization

intermediate
testingunit-testsassertions
šŸŽ® Interactive Playground

What is Unit Testing?

Unit testing verifies individual components work correctly in isolation. Rust has first-class testing support built into the language and Cargo, making it easy to write and run tests alongside your code.

The Problem

Effective unit testing in Rust requires understanding:

  • Test module organization: Where to put tests
  • Assertion macros: Checking expected behavior
  • Test attributes: Control test execution
  • Private function testing: Accessing internal code

Example Code

/// A simple calculator module
pub mod calculator {
    /// Add two numbers
    pub fn add(a: i32, b: i32) -> i32 {
        a + b
    }

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

    /// Multiply two numbers
    pub fn multiply(a: i32, b: i32) -> i32 {
        a * b
    }

    /// Divide a by b, returning None if b is zero
    pub fn divide(a: i32, b: i32) -> Option<i32> {
        if b == 0 {
            None
        } else {
            Some(a / b)
        }
    }

    /// Private helper function
    fn validate_positive(n: i32) -> bool {
        n > 0
    }

    /// Calculate factorial
    pub fn factorial(n: u32) -> u64 {
        if n <= 1 {
            1
        } else {
            n as u64 * factorial(n - 1)
        }
    }

    // Unit tests in the same module
    #[cfg(test)]
    mod tests {
        use super::*;

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

        // Multiple assertions
        #[test]
        fn test_add_various_cases() {
            assert_eq!(add(0, 0), 0);
            assert_eq!(add(-1, 1), 0);
            assert_eq!(add(-5, -3), -8);
            assert_eq!(add(i32::MAX, 0), i32::MAX);
        }

        // Test with message
        #[test]
        fn test_subtract() {
            let result = subtract(10, 4);
            assert_eq!(result, 6, "Expected 10 - 4 = 6, got {}", result);
        }

        // Test that should panic
        #[test]
        #[should_panic]
        fn test_overflow_panics() {
            let _ = add(i32::MAX, 1); // This will panic in debug mode
        }

        // Test with expected panic message
        #[test]
        #[should_panic(expected = "attempt to add with overflow")]
        fn test_overflow_panic_message() {
            let _ = add(i32::MAX, 1);
        }

        // Test Option return values
        #[test]
        fn test_divide_success() {
            assert_eq!(divide(10, 2), Some(5));
        }

        #[test]
        fn test_divide_by_zero() {
            assert_eq!(divide(10, 0), None);
        }

        // Using assert! for boolean conditions
        #[test]
        fn test_multiply_positive() {
            let result = multiply(3, 4);
            assert!(result > 0, "Product of positive numbers should be positive");
            assert!(result == 12);
        }

        // Testing private functions (allowed in same module)
        #[test]
        fn test_validate_positive() {
            assert!(validate_positive(5));
            assert!(!validate_positive(0));
            assert!(!validate_positive(-3));
        }

        // Ignored test (run with `cargo test -- --ignored`)
        #[test]
        #[ignore]
        fn test_expensive_computation() {
            let result = factorial(20);
            assert_eq!(result, 2_432_902_008_176_640_000);
        }
    }
}

/// Testing structs and methods
pub struct BankAccount {
    balance: i64,
    owner: String,
}

impl BankAccount {
    pub fn new(owner: &str, initial_balance: i64) -> Self {
        BankAccount {
            balance: initial_balance,
            owner: owner.to_string(),
        }
    }

    pub fn deposit(&mut self, amount: i64) -> Result<(), &'static str> {
        if amount <= 0 {
            return Err("Deposit amount must be positive");
        }
        self.balance += amount;
        Ok(())
    }

    pub fn withdraw(&mut self, amount: i64) -> Result<(), &'static str> {
        if amount <= 0 {
            return Err("Withdrawal amount must be positive");
        }
        if amount > self.balance {
            return Err("Insufficient funds");
        }
        self.balance -= amount;
        Ok(())
    }

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

    pub fn owner(&self) -> &str {
        &self.owner
    }
}

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

    // Setup helper function
    fn create_account() -> BankAccount {
        BankAccount::new("Test User", 1000)
    }

    #[test]
    fn test_new_account() {
        let account = BankAccount::new("Alice", 500);
        assert_eq!(account.owner(), "Alice");
        assert_eq!(account.balance(), 500);
    }

    #[test]
    fn test_deposit() {
        let mut account = create_account();
        assert!(account.deposit(100).is_ok());
        assert_eq!(account.balance(), 1100);
    }

    #[test]
    fn test_deposit_negative() {
        let mut account = create_account();
        let result = account.deposit(-50);
        assert!(result.is_err());
        assert_eq!(result.unwrap_err(), "Deposit amount must be positive");
    }

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

    #[test]
    fn test_withdraw_insufficient_funds() {
        let mut account = create_account();
        let result = account.withdraw(2000);
        assert!(result.is_err());
        assert_eq!(result.unwrap_err(), "Insufficient funds");
        assert_eq!(account.balance(), 1000); // Balance unchanged
    }

    // Test using matches! macro
    #[test]
    fn test_result_matching() {
        let mut account = create_account();
        let result = account.withdraw(100);
        assert!(matches!(result, Ok(())));

        let result = account.withdraw(10000);
        assert!(matches!(result, Err(_)));
    }
}

/// Testing with fixtures using a test module
pub mod user_service {
    use std::collections::HashMap;

    pub struct User {
        pub id: u64,
        pub name: String,
        pub email: String,
    }

    pub struct UserService {
        users: HashMap<u64, User>,
        next_id: u64,
    }

    impl UserService {
        pub fn new() -> Self {
            UserService {
                users: HashMap::new(),
                next_id: 1,
            }
        }

        pub fn create_user(&mut self, name: &str, email: &str) -> u64 {
            let id = self.next_id;
            self.next_id += 1;
            self.users.insert(id, User {
                id,
                name: name.to_string(),
                email: email.to_string(),
            });
            id
        }

        pub fn get_user(&self, id: u64) -> Option<&User> {
            self.users.get(&id)
        }

        pub fn delete_user(&mut self, id: u64) -> bool {
            self.users.remove(&id).is_some()
        }

        pub fn user_count(&self) -> usize {
            self.users.len()
        }
    }

    impl Default for UserService {
        fn default() -> Self {
            Self::new()
        }
    }

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

        // Fixture struct for test setup
        struct TestFixture {
            service: UserService,
            alice_id: u64,
            bob_id: u64,
        }

        impl TestFixture {
            fn new() -> Self {
                let mut service = UserService::new();
                let alice_id = service.create_user("Alice", "alice@example.com");
                let bob_id = service.create_user("Bob", "bob@example.com");
                TestFixture { service, alice_id, bob_id }
            }
        }

        #[test]
        fn test_create_user() {
            let mut service = UserService::new();
            let id = service.create_user("Test", "test@example.com");
            assert_eq!(id, 1);
            assert_eq!(service.user_count(), 1);
        }

        #[test]
        fn test_get_user_with_fixture() {
            let fixture = TestFixture::new();

            let alice = fixture.service.get_user(fixture.alice_id).unwrap();
            assert_eq!(alice.name, "Alice");
            assert_eq!(alice.email, "alice@example.com");
        }

        #[test]
        fn test_delete_user() {
            let mut fixture = TestFixture::new();

            assert!(fixture.service.delete_user(fixture.alice_id));
            assert_eq!(fixture.service.user_count(), 1);
            assert!(fixture.service.get_user(fixture.alice_id).is_none());
        }

        #[test]
        fn test_delete_nonexistent_user() {
            let mut fixture = TestFixture::new();
            assert!(!fixture.service.delete_user(999));
            assert_eq!(fixture.service.user_count(), 2);
        }
    }
}

fn main() {
    println!("Run tests with: cargo test");
    println!("Run specific test: cargo test test_add");
    println!("Run ignored tests: cargo test -- --ignored");
    println!("Show output: cargo test -- --nocapture");
}

Why This Works

  1. #[cfg(test)]: Test code only compiled when testing
  2. #[test]: Marks function as a test case
  3. assert! macros: Provide clear failure messages
  4. Same module access: Tests can access private functions

Test Organization

src/
ā”œā”€ā”€ lib.rs
│   └── #[cfg(test)] mod tests { }  # Unit tests
ā”œā”€ā”€ calculator.rs
│   └── #[cfg(test)] mod tests { }  # Module tests
tests/
ā”œā”€ā”€ integration_test.rs              # Integration tests
└── common/
    └── mod.rs                       # Shared test utilities

Assertion Macros

| Macro | Purpose | Example |

|-------|---------|---------|

| assert! | Boolean condition | assert!(x > 0) |

| assert_eq! | Equality | assert_eq!(a, b) |

| assert_ne! | Inequality | assert_ne!(a, b) |

| debug_assert! | Debug-only | debug_assert!(valid) |

When to Use

  • Every public function: Ensure API contract
  • Edge cases: Empty inputs, boundaries, errors
  • Regression prevention: After fixing bugs
  • Documentation: Tests as examples

āš ļø Anti-patterns

// DON'T: Test multiple unrelated things
#[test]
fn test_everything() {
    assert_eq!(add(1, 2), 3);
    assert_eq!(multiply(2, 3), 6);
    assert!(divide(10, 2).is_some());
}

// DO: One concept per test
#[test]
fn test_add_positive_numbers() {
    assert_eq!(add(1, 2), 3);
}

// DON'T: Assertions without context
#[test]
fn test_calc() {
    assert!(result > 0);  // What is result? Why > 0?
}

// DO: Clear assertions with messages
#[test]
fn test_deposit_increases_balance() {
    let result = account.balance();
    assert!(result > initial, "Deposit should increase balance");
}

Exercises

  1. Add boundary tests for factorial (0, 1, large numbers)
  2. Create a test fixture for database-like operations
  3. Write tests that verify error messages
  4. Add parameterized-style tests using a helper function

šŸŽ® Try it Yourself

šŸŽ®

Unit Testing - Playground

Run this code in the official Rust Playground