Rust - Testing

Overview

Estimated time: 60–75 minutes

Master Rust's built-in testing framework for writing reliable code. Learn unit tests, integration tests, documentation tests, and test-driven development practices in Rust.

Learning Objectives

Prerequisites

Unit Testing Basics

Your First Test

Use the #[test] attribute to mark functions as tests:

// Basic test structure
#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        let result = 2 + 2;
        assert_eq!(result, 4);
    }
    
    #[test]
    fn basic_addition() {
        assert_eq!(5 + 3, 8);
        assert_eq!(10 + 0, 10);
        assert_eq!(-1 + 1, 0);
    }
    
    #[test]
    fn basic_subtraction() {
        assert_eq!(10 - 5, 5);
        assert_eq!(0 - 0, 0);
        assert_eq!(100 - 99, 1);
    }
}

// Run tests with: cargo test
// Expected output shows tests passing

Assert Macros

Rust provides several assert macros for different types of testing:

// Functions to test
fn divide(a: f64, b: f64) -> Result {
    if b == 0.0 {
        Err("Cannot divide by zero".to_string())
    } else {
        Ok(a / b)
    }
}

fn is_even(n: i32) -> bool {
    n % 2 == 0
}

fn get_name(age: u32) -> Option {
    if age >= 18 {
        Some("Adult".to_string())
    } else {
        None
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    
    #[test]
    fn test_basic_assertions() {
        // assert! - tests boolean conditions
        assert!(2 + 2 == 4);
        assert!(is_even(4));
        assert!(!is_even(3));
    }
    
    #[test]
    fn test_equality_assertions() {
        // assert_eq! - tests equality
        assert_eq!(2 + 2, 4);
        assert_eq!("hello".to_string(), "hello");
        assert_eq!(vec![1, 2, 3], vec![1, 2, 3]);
        
        // assert_ne! - tests inequality
        assert_ne!(2 + 2, 5);
        assert_ne!("hello", "world");
    }
    
    #[test]
    fn test_result_handling() {
        // Testing Ok results
        let result = divide(10.0, 2.0);
        assert!(result.is_ok());
        assert_eq!(result.unwrap(), 5.0);
        
        // Testing Err results
        let error_result = divide(10.0, 0.0);
        assert!(error_result.is_err());
        assert_eq!(error_result.unwrap_err(), "Cannot divide by zero");
    }
    
    #[test]
    fn test_option_handling() {
        // Testing Some values
        let adult = get_name(25);
        assert!(adult.is_some());
        assert_eq!(adult.unwrap(), "Adult");
        
        // Testing None values
        let child = get_name(15);
        assert!(child.is_none());
    }
    
    #[test]
    fn test_with_custom_messages() {
        let x = 5;
        let y = 10;
        
        assert!(x < y, "x should be less than y, but x={}, y={}", x, y);
        assert_eq!(x + y, 15, "Addition failed: {} + {} should equal 15", x, y);
    }
}

Testing Panics and Errors

Expected Panics

Test that code panics under certain conditions:

// Function that might panic
fn divide_ints(a: i32, b: i32) -> i32 {
    if b == 0 {
        panic!("Cannot divide by zero!");
    }
    a / b
}

fn get_element(vec: &Vec, index: usize) -> i32 {
    if index >= vec.len() {
        panic!("Index {} out of bounds for vector of length {}", index, vec.len());
    }
    vec[index]
}

#[cfg(test)]
mod panic_tests {
    use super::*;
    
    #[test]
    #[should_panic]
    fn test_divide_by_zero_panics() {
        divide_ints(10, 0);  // This should panic
    }
    
    #[test]
    #[should_panic(expected = "Cannot divide by zero!")]
    fn test_divide_panic_message() {
        divide_ints(5, 0);  // This should panic with specific message
    }
    
    #[test]
    #[should_panic(expected = "out of bounds")]
    fn test_vector_bounds_panic() {
        let vec = vec![1, 2, 3];
        get_element(&vec, 10);  // This should panic
    }
    
    #[test]
    fn test_normal_division() {
        let result = divide_ints(10, 2);
        assert_eq!(result, 5);
    }
    
    #[test]
    fn test_normal_vector_access() {
        let vec = vec![1, 2, 3, 4, 5];
        let element = get_element(&vec, 2);
        assert_eq!(element, 3);
    }
}

Testing with Result Types

Tests can return Result types for cleaner error handling:

use std::fs;
use std::io;

// Function that returns Result
fn read_config_file(path: &str) -> Result {
    fs::read_to_string(path)
}

fn parse_number(s: &str) -> Result {
    s.trim().parse()
}

#[cfg(test)]
mod result_tests {
    use super::*;
    
    #[test]
    fn test_parse_valid_number() -> Result<(), std::num::ParseIntError> {
        let result = parse_number("42")?;
        assert_eq!(result, 42);
        Ok(())
    }
    
    #[test]
    fn test_parse_whitespace_number() -> Result<(), std::num::ParseIntError> {
        let result = parse_number("  123  ")?;
        assert_eq!(result, 123);
        Ok(())
    }
    
    #[test]
    fn test_parse_negative_number() -> Result<(), std::num::ParseIntError> {
        let result = parse_number("-456")?;
        assert_eq!(result, -456);
        Ok(())
    }
    
    #[test]
    fn test_parse_invalid_number() {
        let result = parse_number("not_a_number");
        assert!(result.is_err());
        
        // Can also test the specific error type
        match result {
            Err(e) => assert_eq!(e.kind(), &std::num::IntErrorKind::InvalidDigit),
            Ok(_) => panic!("Expected an error"),
        }
    }
}

Organizing Tests

Test Module Organization

Structure tests in modules for better organization:

// Calculator implementation
pub struct Calculator {
    memory: f64,
}

impl Calculator {
    pub fn new() -> Self {
        Calculator { memory: 0.0 }
    }
    
    pub fn add(&mut self, value: f64) -> f64 {
        self.memory += value;
        self.memory
    }
    
    pub fn subtract(&mut self, value: f64) -> f64 {
        self.memory -= value;
        self.memory
    }
    
    pub fn multiply(&mut self, value: f64) -> f64 {
        self.memory *= value;
        self.memory
    }
    
    pub fn divide(&mut self, value: f64) -> Result {
        if value == 0.0 {
            Err("Division by zero".to_string())
        } else {
            self.memory /= value;
            Ok(self.memory)
        }
    }
    
    pub fn clear(&mut self) {
        self.memory = 0.0;
    }
    
    pub fn get_memory(&self) -> f64 {
        self.memory
    }
}

#[cfg(test)]
mod calculator_tests {
    use super::*;
    
    mod basic_operations {
        use super::*;
        
        #[test]
        fn test_new_calculator() {
            let calc = Calculator::new();
            assert_eq!(calc.get_memory(), 0.0);
        }
        
        #[test]
        fn test_addition() {
            let mut calc = Calculator::new();
            assert_eq!(calc.add(5.0), 5.0);
            assert_eq!(calc.add(3.0), 8.0);
            assert_eq!(calc.get_memory(), 8.0);
        }
        
        #[test]
        fn test_subtraction() {
            let mut calc = Calculator::new();
            calc.add(10.0);
            assert_eq!(calc.subtract(3.0), 7.0);
            assert_eq!(calc.subtract(2.0), 5.0);
        }
        
        #[test]
        fn test_multiplication() {
            let mut calc = Calculator::new();
            calc.add(4.0);
            assert_eq!(calc.multiply(3.0), 12.0);
            assert_eq!(calc.multiply(0.5), 6.0);
        }
    }
    
    mod division_operations {
        use super::*;
        
        #[test]
        fn test_division() {
            let mut calc = Calculator::new();
            calc.add(20.0);
            
            let result = calc.divide(4.0);
            assert!(result.is_ok());
            assert_eq!(result.unwrap(), 5.0);
        }
        
        #[test]
        fn test_division_by_zero() {
            let mut calc = Calculator::new();
            calc.add(10.0);
            
            let result = calc.divide(0.0);
            assert!(result.is_err());
            assert_eq!(result.unwrap_err(), "Division by zero");
        }
    }
    
    mod memory_operations {
        use super::*;
        
        #[test]
        fn test_clear_memory() {
            let mut calc = Calculator::new();
            calc.add(100.0);
            assert_eq!(calc.get_memory(), 100.0);
            
            calc.clear();
            assert_eq!(calc.get_memory(), 0.0);
        }
        
        #[test]
        fn test_memory_persistence() {
            let mut calc = Calculator::new();
            calc.add(10.0);
            calc.multiply(2.0);
            calc.subtract(5.0);
            
            assert_eq!(calc.get_memory(), 15.0);
        }
    }
}

Integration Tests

tests/ Directory Structure

Integration tests go in the tests/ directory and test your crate as an external user would:

// tests/integration_test.rs
use my_calculator::Calculator;  // Assuming our crate is named my_calculator

#[test]
fn test_calculator_workflow() {
    let mut calc = Calculator::new();
    
    // Test a complete calculation workflow
    calc.add(10.0);
    calc.multiply(2.0);
    calc.subtract(5.0);
    
    let result = calc.divide(3.0);
    assert!(result.is_ok());
    
    // Should be (10 * 2 - 5) / 3 = 15 / 3 = 5
    assert_eq!(result.unwrap(), 5.0);
}

#[test]
fn test_calculator_error_recovery() {
    let mut calc = Calculator::new();
    calc.add(100.0);
    
    // Try division by zero
    let error_result = calc.divide(0.0);
    assert!(error_result.is_err());
    
    // Memory should be unchanged after error
    assert_eq!(calc.get_memory(), 100.0);
    
    // Should be able to continue with other operations
    calc.add(50.0);
    assert_eq!(calc.get_memory(), 150.0);
}

#[test]
fn test_multiple_calculators() {
    let mut calc1 = Calculator::new();
    let mut calc2 = Calculator::new();
    
    calc1.add(10.0);
    calc2.add(20.0);
    
    // Calculators should be independent
    assert_eq!(calc1.get_memory(), 10.0);
    assert_eq!(calc2.get_memory(), 20.0);
    
    calc1.multiply(2.0);
    // calc2 should be unaffected
    assert_eq!(calc1.get_memory(), 20.0);
    assert_eq!(calc2.get_memory(), 20.0);
}

Common Integration Test Patterns

Testing common integration scenarios:

// tests/common/mod.rs - Shared test utilities
use my_calculator::Calculator;

pub fn setup_calculator_with_value(value: f64) -> Calculator {
    let mut calc = Calculator::new();
    calc.add(value);
    calc
}

pub fn perform_standard_operations(calc: &mut Calculator) -> f64 {
    calc.add(10.0);
    calc.multiply(2.0);
    calc.subtract(5.0);
    calc.get_memory()
}

// tests/advanced_integration.rs
mod common;

use my_calculator::Calculator;
use common::{setup_calculator_with_value, perform_standard_operations};

#[test]
fn test_calculator_chain_operations() {
    let operations = vec![
        ("add", 5.0),
        ("multiply", 3.0),
        ("subtract", 2.0),
        ("divide", 2.0),
    ];
    
    let mut calc = Calculator::new();
    let mut expected = 0.0;
    
    for (op, value) in operations {
        match op {
            "add" => {
                calc.add(value);
                expected += value;
            }
            "multiply" => {
                calc.multiply(value);
                expected *= value;
            }
            "subtract" => {
                calc.subtract(value);
                expected -= value;
            }
            "divide" => {
                calc.divide(value).unwrap();
                expected /= value;
            }
            _ => panic!("Unknown operation: {}", op),
        }
        
        assert_eq!(calc.get_memory(), expected);
    }
}

#[test]
fn test_using_common_utilities() {
    let mut calc = setup_calculator_with_value(100.0);
    assert_eq!(calc.get_memory(), 100.0);
    
    let result = perform_standard_operations(&mut calc);
    // 100 + 10 = 110, * 2 = 220, - 5 = 215
    assert_eq!(result, 215.0);
}

Documentation Tests

Testing Code in Documentation

Rust can run code examples in documentation comments:

/// A simple calculator that maintains memory of the last result.
/// 
/// # Examples
/// 
/// Basic usage:
/// 
/// ```
/// use my_calculator::Calculator;
/// 
/// let mut calc = Calculator::new();
/// assert_eq!(calc.add(5.0), 5.0);
/// assert_eq!(calc.multiply(2.0), 10.0);
/// ```
/// 
/// Error handling:
/// 
/// ```
/// use my_calculator::Calculator;
/// 
/// let mut calc = Calculator::new();
/// calc.add(10.0);
/// 
/// let result = calc.divide(0.0);
/// assert!(result.is_err());
/// assert_eq!(result.unwrap_err(), "Division by zero");
/// ```
/// 
/// Memory operations:
/// 
/// ```
/// use my_calculator::Calculator;
/// 
/// let mut calc = Calculator::new();
/// calc.add(100.0);
/// calc.multiply(0.5);
/// assert_eq!(calc.get_memory(), 50.0);
/// 
/// calc.clear();
/// assert_eq!(calc.get_memory(), 0.0);
/// ```
pub struct Calculator {
    memory: f64,
}

impl Calculator {
    /// Creates a new calculator with memory initialized to zero.
    /// 
    /// # Examples
    /// 
    /// ```
    /// use my_calculator::Calculator;
    /// 
    /// let calc = Calculator::new();
    /// assert_eq!(calc.get_memory(), 0.0);
    /// ```
    pub fn new() -> Self {
        Calculator { memory: 0.0 }
    }
    
    /// Adds a value to the current memory.
    /// 
    /// # Arguments
    /// 
    /// * `value` - The value to add
    /// 
    /// # Returns
    /// 
    /// The new memory value after addition
    /// 
    /// # Examples
    /// 
    /// ```
    /// use my_calculator::Calculator;
    /// 
    /// let mut calc = Calculator::new();
    /// assert_eq!(calc.add(5.0), 5.0);
    /// assert_eq!(calc.add(3.0), 8.0);
    /// ```
    pub fn add(&mut self, value: f64) -> f64 {
        self.memory += value;
        self.memory
    }
    
    /// Divides the current memory by a value.
    /// 
    /// # Arguments
    /// 
    /// * `value` - The divisor
    /// 
    /// # Returns
    /// 
    /// `Ok(result)` if division is successful, `Err(message)` if dividing by zero
    /// 
    /// # Examples
    /// 
    /// ```
    /// use my_calculator::Calculator;
    /// 
    /// let mut calc = Calculator::new();
    /// calc.add(20.0);
    /// 
    /// let result = calc.divide(4.0);
    /// assert!(result.is_ok());
    /// assert_eq!(result.unwrap(), 5.0);
    /// 
    /// // Division by zero returns error
    /// let error = calc.divide(0.0);
    /// assert!(error.is_err());
    /// ```
    pub fn divide(&mut self, value: f64) -> Result {
        if value == 0.0 {
            Err("Division by zero".to_string())
        } else {
            self.memory /= value;
            Ok(self.memory)
        }
    }
    
    /// Gets the current memory value.
    /// 
    /// # Examples
    /// 
    /// ```
    /// use my_calculator::Calculator;
    /// 
    /// let mut calc = Calculator::new();
    /// calc.add(42.0);
    /// assert_eq!(calc.get_memory(), 42.0);
    /// ```
    pub fn get_memory(&self) -> f64 {
        self.memory
    }
}

// Run documentation tests with: cargo test --doc

Test-Driven Development (TDD)

TDD Example: Building a String Validator

Following TDD principles: Red → Green → Refactor:

// Step 1: Write failing tests first (RED)
#[cfg(test)]
mod string_validator_tests {
    use super::*;
    
    #[test]
    fn test_email_validation() {
        assert!(is_valid_email("[email protected]"));
        assert!(is_valid_email("[email protected]"));
        assert!(!is_valid_email("invalid.email"));
        assert!(!is_valid_email("@example.com"));
        assert!(!is_valid_email("user@"));
    }
    
    #[test]
    fn test_password_strength() {
        assert_eq!(password_strength("abc"), PasswordStrength::Weak);
        assert_eq!(password_strength("password123"), PasswordStrength::Medium);
        assert_eq!(password_strength("Str0ng!Pass"), PasswordStrength::Strong);
        assert_eq!(password_strength("VeryStr0ng!Password123"), PasswordStrength::VeryStrong);
    }
    
    #[test]
    fn test_username_validation() {
        assert!(is_valid_username("user123"));
        assert!(is_valid_username("cool_user"));
        assert!(!is_valid_username("ab"));           // Too short
        assert!(!is_valid_username("user@domain"));  // Invalid characters
        assert!(!is_valid_username(""));             // Empty
    }
}

// Step 2: Write minimal code to make tests pass (GREEN)
#[derive(Debug, PartialEq)]
pub enum PasswordStrength {
    Weak,
    Medium,
    Strong,
    VeryStrong,
}

pub fn is_valid_email(email: &str) -> bool {
    email.contains('@') && 
    email.contains('.') && 
    !email.starts_with('@') && 
    !email.ends_with('@') &&
    email.len() > 3
}

pub fn password_strength(password: &str) -> PasswordStrength {
    let length = password.len();
    let has_upper = password.chars().any(|c| c.is_uppercase());
    let has_lower = password.chars().any(|c| c.is_lowercase());
    let has_digit = password.chars().any(|c| c.is_digit(10));
    let has_special = password.chars().any(|c| !c.is_alphanumeric());
    
    let criteria_met = [has_upper, has_lower, has_digit, has_special]
        .iter()
        .filter(|&&x| x)
        .count();
    
    match (length, criteria_met) {
        (0..=6, _) => PasswordStrength::Weak,
        (7..=10, 0..=2) => PasswordStrength::Medium,
        (7..=10, 3..=4) => PasswordStrength::Strong,
        (11.., 3..=4) => PasswordStrength::VeryStrong,
        _ => PasswordStrength::Medium,
    }
}

pub fn is_valid_username(username: &str) -> bool {
    username.len() >= 3 &&
    username.len() <= 20 &&
    username.chars().all(|c| c.is_alphanumeric() || c == '_') &&
    !username.is_empty()
}

// Step 3: Refactor with more comprehensive tests
#[cfg(test)]
mod comprehensive_tests {
    use super::*;
    
    mod email_tests {
        use super::*;
        
        #[test]
        fn test_valid_emails() {
            let valid_emails = vec![
                "[email protected]",
                "[email protected]",
                "[email protected]",
                "[email protected]",
            ];
            
            for email in valid_emails {
                assert!(is_valid_email(email), "Email should be valid: {}", email);
            }
        }
        
        #[test]
        fn test_invalid_emails() {
            let invalid_emails = vec![
                "plainaddress",
                "@example.com",
                "user@",
                "[email protected]",
                "",
            ];
            
            for email in invalid_emails {
                assert!(!is_valid_email(email), "Email should be invalid: {}", email);
            }
        }
    }
    
    mod password_tests {
        use super::*;
        
        #[test]
        fn test_password_strength_progression() {
            // Test progression from weak to very strong
            assert_eq!(password_strength("abc"), PasswordStrength::Weak);
            assert_eq!(password_strength("password"), PasswordStrength::Medium);
            assert_eq!(password_strength("Password1"), PasswordStrength::Strong);
            assert_eq!(password_strength("VeryStr0ng!Password"), PasswordStrength::VeryStrong);
        }
        
        #[test]
        fn test_password_criteria() {
            // Test specific criteria
            assert_eq!(password_strength("UPPERCASE"), PasswordStrength::Medium);
            assert_eq!(password_strength("lowercase"), PasswordStrength::Medium);
            assert_eq!(password_strength("12345678"), PasswordStrength::Medium);
            assert_eq!(password_strength("!@#$%^&*"), PasswordStrength::Medium);
        }
    }
    
    mod username_tests {
        use super::*;
        
        #[test]
        fn test_username_length_requirements() {
            assert!(!is_valid_username("ab"));          // Too short
            assert!(is_valid_username("abc"));          // Minimum length
            assert!(is_valid_username("a".repeat(20).as_str())); // Maximum length
        }
        
        #[test]
        fn test_username_character_requirements() {
            assert!(is_valid_username("user123"));      // Alphanumeric
            assert!(is_valid_username("user_name"));    // With underscore
            assert!(!is_valid_username("user-name"));   // With hyphen (invalid)
            assert!(!is_valid_username("user@domain")); // With @ (invalid)
        }
    }
}

Test Configuration and Attributes

Conditional Compilation and Test Attributes

Control when and how tests run:

#[cfg(test)]
mod test_configuration {
    use super::*;
    
    #[test]
    fn regular_test() {
        assert_eq!(2 + 2, 4);
    }
    
    #[test]
    #[ignore]
    fn expensive_test() {
        // This test is ignored by default
        // Run with: cargo test -- --ignored
        println!("This is an expensive test that takes a long time");
        std::thread::sleep(std::time::Duration::from_secs(1));
        assert!(true);
    }
    
    #[test]
    #[should_panic(expected = "overflow")]
    fn test_overflow_panic() {
        // Test that should panic with specific message
        let _result = i32::MAX + 1; // This may or may not overflow depending on build mode
        panic!("overflow occurred");
    }
    
    #[test]
    #[cfg(target_os = "linux")]
    fn linux_specific_test() {
        // This test only runs on Linux
        assert!(true);
    }
    
    #[test]
    #[cfg(debug_assertions)]
    fn debug_only_test() {
        // This test only runs in debug mode
        assert!(true);
    }
    
    #[test]
    fn test_with_timeout() -> Result<(), Box> {
        // Tests can have timeouts (though not built-in, you'd use external crates)
        let start = std::time::Instant::now();
        
        // Simulate some work
        std::thread::sleep(std::time::Duration::from_millis(100));
        
        let duration = start.elapsed();
        assert!(duration < std::time::Duration::from_secs(1));
        
        Ok(())
    }
}

// Custom test harness example
#[cfg(test)]
mod custom_test_setup {
    use std::sync::Once;
    
    static INIT: Once = Once::new();
    
    fn setup() {
        INIT.call_once(|| {
            // Global test setup - runs once
            println!("Setting up global test environment");
        });
    }
    
    #[test]
    fn test_with_setup() {
        setup();
        // Test code here
        assert!(true);
    }
    
    #[test]
    fn another_test_with_setup() {
        setup();
        // Test code here
        assert!(true);
    }
}

Common Pitfalls

⚠️ Testing Private Functions

Keep test modules in the same file to access private items:

// ❌ Wrong - can't access private functions from integration tests
// tests/integration_test.rs
// use my_crate::private_function; // ERROR: private_function is not public

// ✅ Correct - unit tests in same file can access private items
fn private_helper(x: i32) -> i32 {
    x * 2
}

pub fn public_function(x: i32) -> i32 {
    private_helper(x) + 1
}

#[cfg(test)]
mod tests {
    use super::*;
    
    #[test]
    fn test_private_helper() {
        assert_eq!(private_helper(5), 10);  // Can access private function
    }
    
    #[test]
    fn test_public_function() {
        assert_eq!(public_function(5), 11);
    }
}

⚠️ Over-Testing Implementation Details

Focus on testing behavior, not implementation:

// ❌ Bad - testing implementation details
struct Counter {
    internal_vec: Vec,  // Implementation detail
}

impl Counter {
    fn add(&mut self, value: i32) {
        self.internal_vec.push(value);
    }
    
    fn count(&self) -> usize {
        self.internal_vec.len()
    }
}

#[test]
fn bad_test() {
    let mut counter = Counter { internal_vec: vec![] };
    counter.add(1);
    // ❌ Testing internal structure
    assert_eq!(counter.internal_vec, vec![1]);
}

// ✅ Good - testing public behavior
#[test]
fn good_test() {
    let mut counter = Counter { internal_vec: vec![] };
    counter.add(1);
    counter.add(2);
    // ✅ Testing public interface
    assert_eq!(counter.count(), 2);
}

Best Practices

Testing Guidelines

Checks for Understanding

Question 1: What's the difference between unit tests and integration tests?

Answer:

  • Unit tests: Test individual functions/modules in isolation, go in #[cfg(test)] modules within source files, can access private items
  • Integration tests: Test your crate as external users would, go in tests/ directory, only access public API
Question 2: When should you use #[should_panic]?

Answer: Use #[should_panic] when testing that code correctly panics under specific error conditions. Include the expected parameter to verify the panic message. This ensures your error handling works as expected.

Question 3: What are documentation tests and why are they useful?

Answer: Documentation tests are code examples in documentation comments that Rust can compile and run. They're useful because they:

  • Ensure documentation examples actually work
  • Provide real usage examples for users
  • Catch breaking changes in public APIs
  • Serve as additional tests for your code

Summary

Rust's testing framework provides comprehensive tools for reliable software development:


← PreviousNext →